2433 lines
100 KiB
TypeScript
2433 lines
100 KiB
TypeScript
/** Dialog for creating/editing a maintenance task. */
|
||
|
||
import { LitElement, html, css, nothing } from "lit";
|
||
import { property, state } from "lit/decorators.js";
|
||
import type { AdaptiveConfig, HomeAssistant, MaintenanceTask, TaskPartLink, TriggerConfig, HAUser } from "../types";
|
||
import { formatDate, t, weekdayName } from "../styles";
|
||
import { UserService } from "../user-service";
|
||
import { partLinkKey } from "../helpers/shared-parts";
|
||
|
||
import { describeWsError } from "../ws-errors";
|
||
import { REQUIRED_COMPLETION_KEYS, REQUIRED_COMPLETION_LABELS } from "./required-completion-labels";
|
||
import "./ms-textfield";
|
||
|
||
const MAINTENANCE_TYPE_KEYS = ["cleaning", "inspection", "replacement", "calibration", "service", "reading", "custom"];
|
||
const PRIORITY_KEYS = ["low", "normal", "high"];
|
||
const SCHEDULE_TYPE_KEYS = ["time_based", "weekdays", "nth_weekday", "day_of_month", "sensor_based", "one_time", "manual"];
|
||
const CALENDAR_KINDS = ["weekdays", "nth_weekday", "day_of_month"];
|
||
const TRIGGER_TYPE_KEYS = ["threshold", "counter", "state_change", "runtime"];
|
||
// The type selector additionally offers "compound" (a group of conditions
|
||
// joined by AND/OR); its per-condition sub-type is limited to the flat kinds.
|
||
const TRIGGER_TYPE_KEYS_WITH_COMPOUND = [...TRIGGER_TYPE_KEYS, "compound"];
|
||
|
||
/** 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. */
|
||
interface CompoundConditionDraft {
|
||
entityIds: string; // comma-separated raw input
|
||
type: string; // threshold | counter | state_change | runtime
|
||
above: string;
|
||
below: string;
|
||
forMinutes: string;
|
||
targetValue: string;
|
||
deltaMode: boolean;
|
||
fromState: string;
|
||
toState: string;
|
||
targetChanges: string;
|
||
runtimeHours: string;
|
||
onStates: string;
|
||
/** Original keys this editor has no fields for (attribute, baseline, ...).
|
||
* Spread back on save so a compound roundtrip never drops them (#103 class). */
|
||
carry: Partial<TriggerConfig>;
|
||
}
|
||
|
||
function emptyCondition(): CompoundConditionDraft {
|
||
return {
|
||
entityIds: "", type: "threshold", above: "", below: "", forMinutes: "0",
|
||
targetValue: "", deltaMode: false, fromState: "", toState: "",
|
||
targetChanges: "", runtimeHours: "", onStates: "", carry: {},
|
||
};
|
||
}
|
||
|
||
/** Keys the compound editor owns via its own form fields — everything else
|
||
* travels through `carry` untouched. */
|
||
const MANAGED_CONDITION_KEYS = new Set([
|
||
"entity_id", "entity_ids", "type",
|
||
"trigger_above", "trigger_below", "trigger_for_minutes",
|
||
"trigger_target_value", "trigger_delta_mode",
|
||
"trigger_from_state", "trigger_to_state", "trigger_target_changes",
|
||
"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] : []);
|
||
return {
|
||
entityIds: ids.join(", "),
|
||
type: c.type || "threshold",
|
||
above: c.trigger_above?.toString() ?? "",
|
||
below: c.trigger_below?.toString() ?? "",
|
||
forMinutes: c.trigger_for_minutes?.toString() ?? "0",
|
||
targetValue: c.trigger_target_value?.toString() ?? "",
|
||
deltaMode: c.trigger_delta_mode || false,
|
||
fromState: c.trigger_from_state || "",
|
||
toState: c.trigger_to_state || "",
|
||
targetChanges: c.trigger_target_changes?.toString() ?? "",
|
||
runtimeHours: c.trigger_runtime_hours?.toString() ?? "",
|
||
onStates: (c.trigger_on_states || []).join(", "),
|
||
carry: Object.fromEntries(
|
||
Object.entries(c).filter(([k]) => !MANAGED_CONDITION_KEYS.has(k) && !k.startsWith("_")),
|
||
) as Partial<TriggerConfig>,
|
||
};
|
||
}
|
||
|
||
/** Build the persisted condition (storage shape) from an editable draft.
|
||
* Returns null when the condition has no entity (skip it). */
|
||
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.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 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;
|
||
}
|
||
return c;
|
||
}
|
||
|
||
/** Short localized weekday names (0=Mon … 6=Sun) — uses the shared weekdayName (DRY). */
|
||
function weekdayNames(lang?: string): string[] {
|
||
return Array.from({ length: 7 }, (_, i) => weekdayName(i, lang, "short"));
|
||
}
|
||
|
||
/** Short localized month names, indexed 0=Jan … 11=Dec. */
|
||
function monthNames(lang?: string): string[] {
|
||
const fmt = new Intl.DateTimeFormat(lang || "en", { month: "short" });
|
||
return Array.from({ length: 12 }, (_, i) => fmt.format(new Date(2021, i, 1)));
|
||
}
|
||
|
||
export class MaintenanceTaskDialog extends LitElement {
|
||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||
@property({ type: Boolean, attribute: "checklists-enabled" }) public checklistsEnabled = false;
|
||
@property({ type: Boolean, attribute: "schedule-time-enabled" }) public scheduleTimeEnabled = false;
|
||
@property({ type: Boolean, attribute: "completion-actions-enabled" }) public completionActionsEnabled = false;
|
||
@property({ type: Number, attribute: "default-warning-days" }) public defaultWarningDays = 7;
|
||
/** The object's spare parts — offered as "consumes parts" checkboxes. */
|
||
@state() private parts: Array<{ id: string; name: string; unit?: string }> = [];
|
||
/** #111: OTHER objects that own spare parts, so a task can draw on a shared
|
||
* pool (three vacuums, one box of dust bags). Grouped by owner in the UI —
|
||
* which pool a link means must never be a guess. */
|
||
@state() private _foreignOwners: Array<{
|
||
entry_id: string;
|
||
name: string;
|
||
parts: Array<{ id: string; name: string; unit?: string }>;
|
||
}> = [];
|
||
@state() private _open = false;
|
||
@state() private _loading = false;
|
||
@state() private _error = "";
|
||
@state() private _entryId = "";
|
||
@state() private _taskId: string | null = null; // null = create
|
||
// When openCreate is called without an entry_id and a list of objects is supplied,
|
||
// the dialog renders an Object selector dropdown so the user can pick the parent.
|
||
@state() private _objectChoices: Array<{ entry_id: string; name: string }> = [];
|
||
|
||
// Task fields
|
||
@state() private _name = "";
|
||
@state() private _type = "custom";
|
||
@state() private _scheduleType = "time_based";
|
||
@state() private _intervalDays = "30";
|
||
@state() private _intervalUnit = "days";
|
||
@state() private _dueDate = "";
|
||
@state() private _warningDays = "7";
|
||
@state() private _earliestCompletionDays = "";
|
||
@state() private _intervalAnchor: "completion" | "planned" = "completion";
|
||
// Calendar kinds (Phase 4): weekdays (0=Mon…6=Sun), nth_weekday, day_of_month
|
||
@state() private _weekdays: number[] = [];
|
||
@state() private _nth = "1"; // "1".."5" or "-1" (last)
|
||
@state() private _nthWeekday = "5"; // Saturday
|
||
@state() private _domDay = "1";
|
||
// (#83) end-of-month options: last day / business-day rollback / ±N offset.
|
||
@state() private _domLastDay = false;
|
||
@state() private _domBusiness = false;
|
||
@state() private _calOffset = "0";
|
||
// Recurrence extras (apply to interval + calendar kinds): a seasonal active
|
||
// window and a finite-series end condition.
|
||
@state() private _seasonMonths: number[] = [];
|
||
@state() private _endsMode: "never" | "count" | "until" = "never";
|
||
@state() private _endsCount = "";
|
||
@state() private _endsUntil = "";
|
||
// #83: live "next dates" preview — dates come from the BACKEND engine via
|
||
// schedule/preview (never a frontend reimplementation; the #103 lesson).
|
||
@state() private _schedulePreview: string[] = [];
|
||
@state() private _schedulePreviewEnded = false;
|
||
private _previewTimer: ReturnType<typeof setTimeout> | undefined;
|
||
private _previewSeq = 0;
|
||
@state() private _notes = "";
|
||
@state() private _documentationUrl = "";
|
||
@state() private _customIcon = "";
|
||
@state() private _priority = "normal";
|
||
@state() private _labels = "";
|
||
@state() private _enabled = true;
|
||
|
||
// Trigger fields
|
||
@state() private _triggerEntityId = "";
|
||
@state() private _triggerEntityIds: string[] = [];
|
||
@state() private _triggerEntityLogic: "any" | "all" = "any";
|
||
@state() private _triggerAttribute = "";
|
||
@state() private _triggerType = "threshold";
|
||
@state() private _triggerAbove = "";
|
||
@state() private _triggerBelow = "";
|
||
@state() private _triggerForMinutes = "0";
|
||
@state() private _triggerTargetValue = "";
|
||
@state() private _triggerDeltaMode = false;
|
||
@state() private _triggerBaselineValue = "";
|
||
// The LIVE counting anchor from the read-model (Store baseline — moves on
|
||
// completion). Display-only: adopted delta tasks have no config baseline,
|
||
// so without this the edit dialog would show an empty start-value field
|
||
// even though counting is anchored at e.g. 27,000 km.
|
||
@state() private _liveBaselineValue: number | null = null;
|
||
@state() private _autoCompleteOnRecovery = false;
|
||
@state() private _triggerFromState = "";
|
||
@state() private _triggerToState = "";
|
||
@state() private _triggerTargetChanges = "";
|
||
@state() private _triggerRuntimeHours = "";
|
||
// Comma-separated "running" states for the runtime trigger (#103) —
|
||
// empty = the backend default ["on"]. Must roundtrip on edit: adopted
|
||
// tasks ship e.g. ["mowing"], and dropping it on save silently stops
|
||
// the accumulation.
|
||
@state() private _triggerOnStates = "";
|
||
// Compound trigger (type === "compound"): a list of conditions + AND/OR logic
|
||
@state() private _compoundLogic: "AND" | "OR" = "AND";
|
||
@state() private _compoundConditions: CompoundConditionDraft[] = [];
|
||
|
||
// Entity attribute introspection
|
||
@state() private _suggestedAttributes: string[] = [];
|
||
@state() private _availableAttributes: Array<{ name: string; value: unknown; numeric: boolean }> = [];
|
||
@state() private _entityDomain = "";
|
||
|
||
// NFC
|
||
@state() private _lastPerformed = "";
|
||
@state() private _nfcTagId = "";
|
||
// v2.20 (#83): unit for `reading`-type tasks ("kWh", "m³", ...)
|
||
@state() private _readingUnit = "";
|
||
/** 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> = {};
|
||
@state() private _partsLoadFailed = false;
|
||
@state() private _availableTags: Array<{id: string; name: string}> = [];
|
||
|
||
// User assignment
|
||
@state() private _responsibleUserId: string | null = null;
|
||
@state() private _assigneePool: string[] = [];
|
||
@state() private _rotationStrategy = "";
|
||
@state() private _availableUsers: HAUser[] = [];
|
||
|
||
// Checklist (newline-separated steps, one per line)
|
||
@state() private _checklistText = "";
|
||
/** Details this task demands on completion — enforced by the backend on
|
||
* every surface, so a button press or voice command cannot bypass it. */
|
||
@state() private _requiredCompletion: string[] = [];
|
||
|
||
// Schedule time (HH:MM, advanced feature)
|
||
@state() private _scheduleTime = "";
|
||
|
||
// v1.3.0: on_complete_action (gated by completionActionsEnabled)
|
||
// v1.3.1: _actionData is the parsed object (was: _actionDataJson string)
|
||
// so ha-form can drive the data fields when the service schema is known.
|
||
@state() private _actionService = "";
|
||
@state() private _actionTargetEntity = "";
|
||
@state() private _actionData: Record<string, unknown> = {};
|
||
@state() private _actionDataJsonFallback = "";
|
||
@state() private _actionTesting = false;
|
||
@state() private _actionTestResult: "" | "ok" | "error" = "";
|
||
@state() private _actionTestError = "";
|
||
|
||
// v1.3.0: quick_complete_defaults (gated by completionActionsEnabled)
|
||
@state() private _qcNotes = "";
|
||
@state() private _qcCost = "";
|
||
@state() private _qcDuration = "";
|
||
@state() private _qcFeedback: "" | "needed" | "not_needed" = "";
|
||
|
||
// Environmental entity (adaptive_config)
|
||
@state() private _environmentalEntity = "";
|
||
@state() private _environmentalAttribute = "";
|
||
private _environmentalInitial = ""; // for change detection on save
|
||
private _environmentalAttributeInitial = "";
|
||
private _userService: UserService | null = null;
|
||
|
||
private get _lang(): string {
|
||
return this.hass?.language ?? navigator.language.split("-")[0] ?? "en";
|
||
}
|
||
|
||
public async openCreate(entryId: string, objects?: Array<{ entry_id: string; object: { name: string } }>): Promise<void> {
|
||
this._entryId = entryId;
|
||
this._taskId = null;
|
||
this._error = "";
|
||
// If no entryId is preset but caller passed objects, expose them as a dropdown.
|
||
// Sort alphabetically by name so the user doesn't have to scan for a target
|
||
// object in creation order (#40). First object after sort becomes the
|
||
// default selection so save can work without forced UI.
|
||
if (!entryId && objects && objects.length > 0) {
|
||
this._objectChoices = objects
|
||
.map(o => ({ entry_id: o.entry_id, name: o.object.name }))
|
||
.sort((a, b) => a.name.localeCompare(b.name));
|
||
this._entryId = this._objectChoices[0].entry_id;
|
||
} else {
|
||
this._objectChoices = [];
|
||
}
|
||
this._resetFields();
|
||
await Promise.all([this._loadUsers(), this._loadTags(), this._loadParts(), this._loadForeignPools()]);
|
||
this._open = true;
|
||
}
|
||
|
||
public async openEdit(entryId: string, task: MaintenanceTask): Promise<void> {
|
||
this._entryId = entryId;
|
||
this._taskId = task.id;
|
||
this._error = "";
|
||
this._name = task.name;
|
||
this._type = task.type;
|
||
this._scheduleType = task.schedule_type;
|
||
// Preserve null (= no safety interval set) instead of forcing "30".
|
||
// Bug #42: the old `?.toString() || "30"` fallback reverted a cleared
|
||
// safety_interval back to 30 on next edit, silently overwriting the
|
||
// user's intent the moment they touched any other field and saved.
|
||
this._intervalDays = task.interval_days != null ? String(task.interval_days) : "";
|
||
this._intervalUnit = task.interval_unit || "days";
|
||
this._dueDate = task.due_date || "";
|
||
// Calendar kinds read the nested schedule (the only place they live).
|
||
const sched = task.schedule;
|
||
this._weekdays = sched?.kind === "weekdays" ? [...(sched.weekdays ?? [])] : [];
|
||
this._nth = sched?.kind === "nth_weekday" ? String(sched.nth ?? 1) : "1";
|
||
this._nthWeekday = sched?.kind === "nth_weekday" ? String(sched.weekday ?? 5) : "5";
|
||
this._domDay = sched?.kind === "day_of_month" && (sched.day ?? 1) >= 1 ? String(sched.day ?? 1) : "1";
|
||
this._domLastDay = sched?.kind === "day_of_month" && sched.day === -1;
|
||
this._domBusiness = sched?.kind === "day_of_month" && sched.business === true;
|
||
this._calOffset = sched?.offset ? String(sched.offset) : "0";
|
||
// Recurrence extras — read from the nested schedule wherever it lives.
|
||
this._seasonMonths = Array.isArray(sched?.season_months) ? [...sched!.season_months] : [];
|
||
const ends = sched?.ends;
|
||
if (ends && typeof ends.count === "number") {
|
||
this._endsMode = "count"; this._endsCount = String(ends.count); this._endsUntil = "";
|
||
} else if (ends && typeof ends.until === "string") {
|
||
this._endsMode = "until"; this._endsUntil = ends.until; this._endsCount = "";
|
||
} else {
|
||
this._endsMode = "never"; this._endsCount = ""; this._endsUntil = "";
|
||
}
|
||
this._warningDays = task.warning_days.toString();
|
||
this._earliestCompletionDays =
|
||
task.earliest_completion_days != null ? String(task.earliest_completion_days) : "";
|
||
this._intervalAnchor = task.interval_anchor || "completion";
|
||
this._notes = task.notes || "";
|
||
this._documentationUrl = task.documentation_url || "";
|
||
this._customIcon = task.custom_icon || "";
|
||
this._priority = task.priority || "normal";
|
||
this._labels = (task.labels || []).join(", ");
|
||
this._enabled = task.enabled !== false;
|
||
this._lastPerformed = task.last_performed || "";
|
||
this._nfcTagId = task.nfc_tag_id || "";
|
||
this._readingUnit = task.reading_unit || "";
|
||
// Whole link, entry_id included — hydrating only part_id would turn every
|
||
// shared-pool link into an own-part link on the next save (#111).
|
||
this._consumesParts = Object.fromEntries(
|
||
(task.consumes_parts || []).map((l) => [partLinkKey(l), { ...l }]),
|
||
);
|
||
this._responsibleUserId = task.responsible_user_id || null;
|
||
this._assigneePool = [...(task.assignee_pool || [])];
|
||
this._rotationStrategy = task.rotation_strategy || "";
|
||
|
||
this._checklistText = (task.checklist || []).join("\n");
|
||
this._requiredCompletion = [...(task.required_completion_fields || [])];
|
||
this._scheduleTime = task.schedule_time || "";
|
||
|
||
// v1.3.0: hydrate on_complete_action + quick_complete_defaults
|
||
const oca = task.on_complete_action;
|
||
if (oca && oca.service) {
|
||
this._actionService = oca.service;
|
||
const tgt = oca.target?.entity_id;
|
||
this._actionTargetEntity = Array.isArray(tgt) ? (tgt[0] || "") : (tgt || "");
|
||
this._actionData = (oca.data && typeof oca.data === "object") ? { ...oca.data } : {};
|
||
this._actionDataJsonFallback = "";
|
||
} else {
|
||
this._actionService = "";
|
||
this._actionTargetEntity = "";
|
||
this._actionData = {};
|
||
this._actionDataJsonFallback = "";
|
||
}
|
||
const qcd = task.quick_complete_defaults;
|
||
this._qcNotes = qcd?.notes || "";
|
||
this._qcCost = qcd?.cost != null ? String(qcd.cost) : "";
|
||
this._qcDuration = qcd?.duration != null ? String(qcd.duration) : "";
|
||
this._qcFeedback = (qcd?.feedback as "needed" | "not_needed" | undefined) || "";
|
||
|
||
const ac: Partial<AdaptiveConfig> = task.adaptive_config || {};
|
||
this._environmentalEntity = ac.environmental_entity || "";
|
||
this._environmentalAttribute = ac.environmental_attribute || "";
|
||
this._environmentalInitial = this._environmentalEntity;
|
||
this._environmentalAttributeInitial = this._environmentalAttribute;
|
||
|
||
if (task.trigger_config) {
|
||
const tc = task.trigger_config;
|
||
// A trigger stored with only the plural entity_ids (e.g. the Battery
|
||
// Fleet task) must still hydrate the singular field — the save path
|
||
// gates on _triggerEntityId and would otherwise NULL the whole trigger
|
||
// on an unrelated edit (issue #106).
|
||
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._triggerForMinutes = tc.trigger_for_minutes?.toString() || "0";
|
||
this._triggerTargetValue = tc.trigger_target_value?.toString() || "";
|
||
this._triggerDeltaMode = tc.trigger_delta_mode || false;
|
||
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._triggerOnStates = (tc.trigger_on_states || []).join(", ");
|
||
if (tc.type === "compound") {
|
||
this._compoundLogic = tc.compound_logic === "OR" ? "OR" : "AND";
|
||
this._compoundConditions = (tc.conditions || []).map(conditionToDraft);
|
||
} else {
|
||
this._compoundLogic = "AND";
|
||
this._compoundConditions = [];
|
||
}
|
||
} else {
|
||
this._resetTriggerFields();
|
||
}
|
||
|
||
// Fetch entity attributes if trigger entity is set
|
||
if (this._triggerEntityId) {
|
||
this._fetchEntityAttributes(this._triggerEntityId);
|
||
}
|
||
|
||
await Promise.all([this._loadUsers(), this._loadTags(), this._loadParts(), this._loadForeignPools()]);
|
||
this._open = true;
|
||
}
|
||
|
||
private _resetFields(): void {
|
||
this._name = "";
|
||
this._type = "custom";
|
||
this._scheduleType = "time_based";
|
||
this._intervalDays = "30";
|
||
this._intervalUnit = "days";
|
||
this._dueDate = "";
|
||
this._warningDays = String(this.defaultWarningDays);
|
||
this._earliestCompletionDays = "";
|
||
this._intervalAnchor = "completion";
|
||
this._weekdays = [];
|
||
this._nth = "1";
|
||
this._nthWeekday = "5";
|
||
this._domDay = "1";
|
||
this._domLastDay = false;
|
||
this._domBusiness = false;
|
||
this._calOffset = "0";
|
||
this._seasonMonths = [];
|
||
this._endsMode = "never";
|
||
this._endsCount = "";
|
||
this._endsUntil = "";
|
||
this._notes = "";
|
||
this._documentationUrl = "";
|
||
this._customIcon = "";
|
||
this._priority = "normal";
|
||
this._labels = "";
|
||
this._enabled = true;
|
||
this._lastPerformed = "";
|
||
this._nfcTagId = "";
|
||
this._readingUnit = "";
|
||
this._consumesParts = {};
|
||
this._responsibleUserId = null;
|
||
this._assigneePool = [];
|
||
this._rotationStrategy = "";
|
||
this._checklistText = "";
|
||
this._requiredCompletion = [];
|
||
this._scheduleTime = "";
|
||
this._environmentalEntity = "";
|
||
this._environmentalAttribute = "";
|
||
this._environmentalInitial = "";
|
||
this._environmentalAttributeInitial = "";
|
||
// v1.3.0
|
||
this._actionService = "";
|
||
this._actionTargetEntity = "";
|
||
this._actionData = {};
|
||
this._actionDataJsonFallback = "";
|
||
this._actionTesting = false;
|
||
this._actionTestResult = "";
|
||
this._qcNotes = "";
|
||
this._qcCost = "";
|
||
this._qcDuration = "";
|
||
this._qcFeedback = "";
|
||
this._resetTriggerFields();
|
||
}
|
||
|
||
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._triggerForMinutes = "0";
|
||
this._triggerTargetValue = "";
|
||
this._triggerDeltaMode = false;
|
||
this._triggerBaselineValue = "";
|
||
this._liveBaselineValue = null;
|
||
this._autoCompleteOnRecovery = false;
|
||
this._triggerFromState = "";
|
||
this._triggerToState = "";
|
||
this._triggerTargetChanges = "";
|
||
this._triggerRuntimeHours = "";
|
||
this._triggerOnStates = "";
|
||
this._compoundLogic = "AND";
|
||
this._compoundConditions = [];
|
||
}
|
||
|
||
private async _loadUsers(): Promise<void> {
|
||
if (!this._userService) {
|
||
this._userService = new UserService(this.hass);
|
||
}
|
||
try {
|
||
this._availableUsers = await this._userService.getUsers();
|
||
} catch (error) {
|
||
console.error("Failed to load users:", error);
|
||
this._availableUsers = [];
|
||
}
|
||
}
|
||
|
||
private _toggleAssignee(userId: string): void {
|
||
this._assigneePool = this._assigneePool.includes(userId)
|
||
? this._assigneePool.filter((u) => u !== userId)
|
||
: [...this._assigneePool, userId];
|
||
}
|
||
|
||
// v1.3.0: fire the configured action immediately so the user can verify
|
||
// it works before saving the task. Doesn't persist anything.
|
||
private async _testAction(): Promise<void> {
|
||
// VALIDATE-ONLY — does NOT actually fire the service-call.
|
||
//
|
||
// Earlier this method called hass.callService(...) for real, which
|
||
// had real side effects: counter.increment incremented the counter,
|
||
// input_button.press fired the button (which on the user's vacuum
|
||
// robot would actually reset the dirty-time sensor as if maintenance
|
||
// had been performed). User pushback (correct):
|
||
// *"setzt Test auch den zustand? dann würde beim testen bereits so
|
||
// getan als ob eine Wartung ausgeführt wurde — das ist nicht gut"*
|
||
//
|
||
// The Test button is for verifying the *configuration* is correct
|
||
// before saving — not for triggering the action. The user can mark
|
||
// the task complete to actually fire it.
|
||
//
|
||
// Checks performed (all without side effects):
|
||
// 1. Service format matches `domain.service` regex
|
||
// 2. Service exists in hass.services registry
|
||
// 3. Domain whitelist for cross-domain services
|
||
// 4. Service-entity domain match (otherwise HA silently drops the
|
||
// call at fire-time with "Referenced entities ... missing or
|
||
// not currently available")
|
||
// 5. Target entity exists in hass.states (when target set)
|
||
const svc = this._actionService.trim();
|
||
if (!svc || !/^[a-z][a-z0-9_]*\.[a-z0-9_]+$/.test(svc)) {
|
||
this._actionTestResult = "error";
|
||
this._actionTestError = "Invalid service format (expected 'domain.service')";
|
||
setTimeout(() => { this._actionTestResult = ""; this._actionTestError = ""; }, 5000);
|
||
return;
|
||
}
|
||
const [domain, name] = svc.split(".");
|
||
|
||
// 2. Service exists?
|
||
if (!this.hass?.services?.[domain]?.[name]) {
|
||
this._actionTestResult = "error";
|
||
this._actionTestError = `Service "${svc}" is not registered in Home Assistant. Check spelling and that the integration providing it is loaded.`;
|
||
setTimeout(() => { this._actionTestResult = ""; this._actionTestError = ""; }, 8000);
|
||
return;
|
||
}
|
||
|
||
const tgt = this._actionTargetEntity.trim();
|
||
|
||
// 3 + 4. Cross-domain whitelist + domain match.
|
||
// homeassistant.* / scene.* / notify.* / persistent_notification.*
|
||
// legitimately accept cross-domain targets; everything else needs the
|
||
// entity domain to match the service domain.
|
||
if (tgt) {
|
||
const entityDomain = tgt.split(".")[0];
|
||
const crossDomainServices = new Set([
|
||
"homeassistant", "scene", "notify", "persistent_notification",
|
||
]);
|
||
if (entityDomain !== domain && !crossDomainServices.has(domain)) {
|
||
this._actionTestResult = "error";
|
||
this._actionTestError = `Service "${svc}" only works on ${domain}.* entities; entity "${tgt}" is in ${entityDomain}.* — pick a service that matches the entity domain (e.g. ${entityDomain}.${name})`;
|
||
setTimeout(() => { this._actionTestResult = ""; this._actionTestError = ""; }, 8000);
|
||
return;
|
||
}
|
||
// 5. Entity exists?
|
||
if (!this.hass.states?.[tgt]) {
|
||
this._actionTestResult = "error";
|
||
this._actionTestError = `Target entity "${tgt}" not found in Home Assistant — the entity may have been renamed or its integration removed.`;
|
||
setTimeout(() => { this._actionTestResult = ""; this._actionTestError = ""; }, 8000);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// All checks passed — configuration is valid. The action will fire
|
||
// when the task is marked complete (not now).
|
||
this._actionTestResult = "ok";
|
||
setTimeout(() => { this._actionTestResult = ""; this._actionTestError = ""; }, 5000);
|
||
}
|
||
|
||
// v1.3.1: derive the data dict from either the schema-driven _actionData
|
||
// (preferred) or the JSON fallback textfield. Returns {} on any parse
|
||
// problem so the caller still gets a usable empty object.
|
||
private _buildActionData(): Record<string, unknown> {
|
||
if (this._actionDataJsonFallback.trim()) {
|
||
try {
|
||
const parsed = JSON.parse(this._actionDataJsonFallback);
|
||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||
return parsed as Record<string, unknown>;
|
||
}
|
||
} catch { /* fallthrough to ha-form data */ }
|
||
}
|
||
return { ...this._actionData };
|
||
}
|
||
|
||
// v1.3.1: look up the selected service in hass.services and convert its
|
||
// `fields` map into the schema shape ha-form expects. Returns null when
|
||
// the service is unknown or has no fields metadata — caller falls back
|
||
// to a free-form JSON textfield.
|
||
private _serviceSchema(): Array<{
|
||
name: string;
|
||
required: boolean;
|
||
selector: Record<string, unknown>;
|
||
}> | null {
|
||
const svc = this._actionService.trim();
|
||
if (!svc || !/^[a-z][a-z0-9_]*\.[a-z0-9_]+$/.test(svc)) return null;
|
||
const [domain, name] = svc.split(".");
|
||
const fields = this.hass?.services?.[domain]?.[name]?.fields;
|
||
if (!fields || Object.keys(fields).length === 0) return null;
|
||
return Object.entries(fields).map(([fname, def]) => ({
|
||
name: fname,
|
||
required: !!def.required,
|
||
selector: (def.selector as Record<string, unknown>) || { text: {} },
|
||
}));
|
||
}
|
||
|
||
private _renderCompletionActionsSection(L: string) {
|
||
if (!this.completionActionsEnabled) return nothing;
|
||
const schema = this._serviceSchema();
|
||
return html`
|
||
<details class="ca-section">
|
||
<summary>${t("on_complete_action_title", L)}</summary>
|
||
<p class="field-help">${t("on_complete_action_desc", L)}</p>
|
||
<ha-service-picker
|
||
.hass=${this.hass}
|
||
.value=${this._actionService}
|
||
@value-changed=${(e: CustomEvent) => {
|
||
this._actionService = e.detail.value || "";
|
||
// Schema changed → drop fields the new service doesn't accept.
|
||
const newSchema = this._serviceSchema();
|
||
if (newSchema) {
|
||
const allowed = new Set(newSchema.map(f => f.name));
|
||
this._actionData = Object.fromEntries(
|
||
Object.entries(this._actionData).filter(([k]) => allowed.has(k)),
|
||
);
|
||
}
|
||
}}
|
||
></ha-service-picker>
|
||
<ha-form
|
||
.hass=${this.hass}
|
||
.schema=${[{
|
||
name: "target_entity",
|
||
selector: { entity: {} },
|
||
}]}
|
||
.data=${{ target_entity: this._actionTargetEntity }}
|
||
.computeLabel=${() => t("on_complete_action_target", L)}
|
||
@value-changed=${(e: CustomEvent) => {
|
||
const v = e.detail.value as { target_entity?: string };
|
||
this._actionTargetEntity = v.target_entity || "";
|
||
}}
|
||
></ha-form>
|
||
<p class="field-help ca-domain-hint">
|
||
${t("on_complete_action_target_hint", L)}
|
||
</p>
|
||
${schema
|
||
? html`
|
||
<ha-form
|
||
class="ca-data-form"
|
||
.hass=${this.hass}
|
||
.schema=${schema}
|
||
.data=${this._actionData}
|
||
@value-changed=${(e: CustomEvent) => {
|
||
this._actionData = { ...(e.detail.value as Record<string, unknown>) };
|
||
}}
|
||
></ha-form>
|
||
`
|
||
: html`
|
||
<ms-textfield
|
||
label="${t("on_complete_action_data", L)}"
|
||
placeholder="{}"
|
||
.value=${this._actionDataJsonFallback}
|
||
@input=${(e: Event) => { this._actionDataJsonFallback = (e.target as HTMLInputElement).value; }}
|
||
></ms-textfield>
|
||
`}
|
||
<div class="ca-test-row">
|
||
<button type="button" ?disabled=${this._actionTesting || !this._actionService}
|
||
@click=${this._testAction}>
|
||
${this._actionTesting ? "…" : t("on_complete_action_test", L)}
|
||
</button>
|
||
${this._actionTestResult === "ok"
|
||
? html`<span class="ca-test-ok">${t("on_complete_action_test_success", L)}</span>`
|
||
: nothing}
|
||
${this._actionTestResult === "error"
|
||
? html`<div class="ca-test-error-block">
|
||
<span class="ca-test-error">${t("on_complete_action_test_failed", L)}</span>
|
||
${this._actionTestError
|
||
? html`<div class="ca-test-error-detail">${this._actionTestError}</div>`
|
||
: nothing}
|
||
</div>`
|
||
: nothing}
|
||
</div>
|
||
</details>
|
||
|
||
<details class="ca-section">
|
||
<summary>${t("quick_complete_defaults_title", L)}</summary>
|
||
<p class="field-help">${t("quick_complete_defaults_desc", L)}</p>
|
||
<ms-textfield
|
||
label="${t("quick_complete_defaults_notes", L)}"
|
||
.value=${this._qcNotes}
|
||
@input=${(e: Event) => { this._qcNotes = (e.target as HTMLInputElement).value; }}
|
||
></ms-textfield>
|
||
<ms-textfield
|
||
label="${t("quick_complete_defaults_cost", L)}"
|
||
type="number" min="0" step="0.01"
|
||
.value=${this._qcCost}
|
||
@input=${(e: Event) => { this._qcCost = (e.target as HTMLInputElement).value; }}
|
||
></ms-textfield>
|
||
<ms-textfield
|
||
label="${t("quick_complete_defaults_duration", L)}"
|
||
type="number" min="0" step="1"
|
||
.value=${this._qcDuration}
|
||
@input=${(e: Event) => { this._qcDuration = (e.target as HTMLInputElement).value; }}
|
||
></ms-textfield>
|
||
<select class="qc-feedback"
|
||
.value=${this._qcFeedback}
|
||
@change=${(e: Event) => { this._qcFeedback = (e.target as HTMLSelectElement).value as "" | "needed" | "not_needed"; }}>
|
||
<option value="">${t("quick_complete_defaults_feedback_none", L)}</option>
|
||
<option value="needed">${t("quick_complete_defaults_feedback_needed", L)}</option>
|
||
<option value="not_needed">${t("quick_complete_defaults_feedback_not_needed", L)}</option>
|
||
</select>
|
||
</details>
|
||
`;
|
||
}
|
||
|
||
private async _loadParts(): Promise<void> {
|
||
// The object's parts back the "consumes parts" checkboxes. Self-loaded so
|
||
// every dialog opener (panel, card, task detail) gets them without
|
||
// threading props through.
|
||
this.parts = [];
|
||
if (!this._entryId) return;
|
||
try {
|
||
const result = (await this.hass.connection.sendMessagePromise({
|
||
type: "maintenance_supporter/object",
|
||
entry_id: this._entryId,
|
||
})) as { parts?: Array<{ id: string; name: string; unit?: string }> };
|
||
this.parts = result.parts || [];
|
||
this._partsLoadFailed = false;
|
||
} catch {
|
||
// Surface the failure: an empty-but-failed load must not just hide the
|
||
// "consumes parts" section as if the object had no parts.
|
||
this.parts = [];
|
||
this._partsLoadFailed = true;
|
||
}
|
||
}
|
||
|
||
/** #111: the other objects' spare-part pools this task could draw on.
|
||
*
|
||
* A sibling of `_loadParts`, run in the SAME Promise.all rather than nested
|
||
* inside it: chaining it after the own-parts fetch delays the dialog opening
|
||
* by a further round trip for a list that is secondary to it.
|
||
*
|
||
* Failure is soft on purpose — the own-parts picker is the primary path and
|
||
* must not disappear because this second call did not come back. */
|
||
private async _loadForeignPools(): Promise<void> {
|
||
this._foreignOwners = [];
|
||
if (!this._entryId) return;
|
||
try {
|
||
const result = (await this.hass.connection.sendMessagePromise({
|
||
type: "maintenance_supporter/objects",
|
||
})) as {
|
||
objects?: Array<{
|
||
entry_id: string;
|
||
object?: { name?: string };
|
||
parts?: Array<{ id: string; name: string; unit?: string }>;
|
||
}>;
|
||
};
|
||
this._foreignOwners = (result.objects || [])
|
||
.filter((o) => o.entry_id !== this._entryId && (o.parts || []).length > 0)
|
||
.map((o) => ({
|
||
entry_id: o.entry_id,
|
||
name: o.object?.name || o.entry_id,
|
||
parts: o.parts || [],
|
||
}))
|
||
.sort((a, b) => a.name.localeCompare(b.name));
|
||
} catch {
|
||
this._foreignOwners = [];
|
||
}
|
||
}
|
||
|
||
private async _loadTags(): Promise<void> {
|
||
try {
|
||
const result = await this.hass.connection.sendMessagePromise({
|
||
type: "maintenance_supporter/tags/list",
|
||
}) as { tags: Array<{id: string; name: string}> };
|
||
this._availableTags = result.tags || [];
|
||
} catch {
|
||
this._availableTags = [];
|
||
}
|
||
}
|
||
|
||
private async _fetchEntityAttributes(entityId: string): Promise<void> {
|
||
if (!entityId || !this.hass) {
|
||
this._suggestedAttributes = [];
|
||
this._availableAttributes = [];
|
||
this._entityDomain = "";
|
||
return;
|
||
}
|
||
try {
|
||
const result = await this.hass.connection.sendMessagePromise({
|
||
type: "maintenance_supporter/entity/attributes",
|
||
entity_id: entityId,
|
||
}) as {
|
||
domain: string;
|
||
suggested_attributes: string[];
|
||
available_attributes: Array<{ name: string; value: unknown; numeric: boolean }>;
|
||
};
|
||
this._entityDomain = result.domain || "";
|
||
this._suggestedAttributes = result.suggested_attributes || [];
|
||
this._availableAttributes = result.available_attributes || [];
|
||
} catch {
|
||
this._suggestedAttributes = [];
|
||
this._availableAttributes = [];
|
||
this._entityDomain = "";
|
||
}
|
||
}
|
||
|
||
/** A task already drawing on a shared pool opens that section expanded — a
|
||
* collapsed disclosure would hide a link that is very much active. */
|
||
private get _hasForeignPick(): boolean {
|
||
return Object.values(this._consumesParts).some((l) => !!l.entry_id);
|
||
}
|
||
|
||
/** One "consumes parts" checkbox + quantity.
|
||
*
|
||
* `ownerEntryId` is undefined for the object's own parts and set for a pool
|
||
* owned by another object (#111) — that argument is the ONLY difference
|
||
* between the two lists, which is why they share this renderer. */
|
||
private _renderConsumesRow(
|
||
part: { id: string; name: string; unit?: string },
|
||
ownerEntryId?: string,
|
||
) {
|
||
const key = partLinkKey({ part_id: part.id, entry_id: ownerEntryId });
|
||
const link = this._consumesParts[key];
|
||
const base: TaskPartLink = ownerEntryId
|
||
? { part_id: part.id, quantity: 1, entry_id: ownerEntryId }
|
||
: { part_id: part.id, quantity: 1 };
|
||
return html`
|
||
<div class="consumes-row">
|
||
<label class="consumes-check">
|
||
<input
|
||
type="checkbox"
|
||
.checked=${link !== undefined}
|
||
@change=${(e: Event) => {
|
||
const next = { ...this._consumesParts };
|
||
if ((e.target as HTMLInputElement).checked) next[key] = next[key] || base;
|
||
else delete next[key];
|
||
this._consumesParts = next;
|
||
}}
|
||
/>
|
||
<span>${part.name}${part.unit ? ` (${part.unit})` : ""}</span>
|
||
</label>
|
||
${link !== undefined
|
||
? html`<input
|
||
class="consumes-qty"
|
||
type="number"
|
||
min="0.01"
|
||
max="999"
|
||
step="0.01"
|
||
.value=${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 },
|
||
};
|
||
}}
|
||
/>`
|
||
: nothing}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
private _toggleRequired(field: string, on: boolean): void {
|
||
const next = new Set(this._requiredCompletion);
|
||
if (on) next.add(field);
|
||
else next.delete(field);
|
||
this._requiredCompletion = [...next];
|
||
}
|
||
|
||
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 {
|
||
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;
|
||
data.priority = this._priority;
|
||
data.labels = this._labels
|
||
.split(",")
|
||
.map((s) => s.trim())
|
||
.filter(Boolean);
|
||
data.enabled = this._enabled;
|
||
data.last_performed = this._lastPerformed || null;
|
||
data.nfc_tag_id = this._nfcTagId || null;
|
||
data.reading_unit = this._readingUnit.trim() || 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;
|
||
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;
|
||
|
||
// 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._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; }
|
||
} else if (this._triggerType === "runtime") {
|
||
if (this._triggerRuntimeHours) { const v = parseFloat(this._triggerRuntimeHours); if (!isNaN(v)) triggerConfig.trigger_runtime_hours = 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 — empty string clears.
|
||
if (this.scheduleTimeEnabled && this._scheduleType === "time_based") {
|
||
const t = 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 };
|
||
const savedTaskId = this._taskId || result?.task_id;
|
||
|
||
// Environmental entity lives in adaptive_config (Store-managed),
|
||
// so it has a dedicated endpoint. Only call it when something
|
||
// actually changed, and only for sensor_based tasks.
|
||
const envChanged =
|
||
this._environmentalEntity !== this._environmentalInitial
|
||
|| this._environmentalAttribute !== this._environmentalAttributeInitial;
|
||
if (
|
||
savedTaskId
|
||
&& this._scheduleType === "sensor_based"
|
||
&& envChanged
|
||
) {
|
||
try {
|
||
await this.hass.connection.sendMessagePromise({
|
||
type: "maintenance_supporter/task/set_environmental_entity",
|
||
entry_id: this._entryId,
|
||
task_id: savedTaskId,
|
||
environmental_entity: this._environmentalEntity || null,
|
||
environmental_attribute: this._environmentalAttribute || null,
|
||
});
|
||
this._environmentalInitial = this._environmentalEntity;
|
||
this._environmentalAttributeInitial = this._environmentalAttribute;
|
||
} catch {
|
||
/* non-fatal — task itself saved */
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
private _close(): void {
|
||
this._open = false;
|
||
}
|
||
|
||
private _renderTriggerFields() {
|
||
if (this._scheduleType !== "sensor_based") return nothing;
|
||
const L = this._lang;
|
||
|
||
const isCompound = this._triggerType === "compound";
|
||
return html`
|
||
<h3>${t("trigger_configuration", L)}</h3>
|
||
<div class="select-row">
|
||
<label>${t("trigger_type", L)}</label>
|
||
<select
|
||
.value=${this._triggerType}
|
||
@change=${(e: Event) => (this._triggerType = (e.target as HTMLSelectElement).value)}
|
||
>
|
||
${TRIGGER_TYPE_KEYS_WITH_COMPOUND.map(
|
||
(key) => html`<option value=${key} ?selected=${key === this._triggerType}>${t(key, L)}</option>`
|
||
)}
|
||
</select>
|
||
</div>
|
||
${isCompound ? this._renderCompoundEditor() : html`
|
||
<ms-textfield
|
||
label="${t("entity_id", L)} (${t("comma_separated", L)})"
|
||
.value=${this._triggerEntityIds.length > 0 ? this._triggerEntityIds.join(", ") : this._triggerEntityId}
|
||
@input=${(e: Event) => {
|
||
const raw = (e.target as HTMLInputElement).value;
|
||
const ids = raw.split(",").map((s: string) => s.trim()).filter(Boolean);
|
||
this._triggerEntityId = ids[0] || "";
|
||
this._triggerEntityIds = ids;
|
||
if (ids[0]) this._fetchEntityAttributes(ids[0]);
|
||
}}
|
||
></ms-textfield>
|
||
${this._triggerEntityIds.length > 1 ? html`
|
||
<div class="select-row">
|
||
<label>${t("entity_logic", L)}</label>
|
||
<select
|
||
.value=${this._triggerEntityLogic}
|
||
@change=${(e: Event) => (this._triggerEntityLogic = (e.target as HTMLSelectElement).value as "any" | "all")}
|
||
>
|
||
<option value="any" ?selected=${this._triggerEntityLogic === "any"}>${t("entity_logic_any", L)}</option>
|
||
<option value="all" ?selected=${this._triggerEntityLogic === "all"}>${t("entity_logic_all", L)}</option>
|
||
</select>
|
||
</div>
|
||
` : nothing}
|
||
${this._availableAttributes.length > 0
|
||
? html`
|
||
<div class="select-row">
|
||
<label>${t("attribute_optional", L)}</label>
|
||
<select
|
||
.value=${this._triggerAttribute}
|
||
@change=${(e: Event) => (this._triggerAttribute = (e.target as HTMLSelectElement).value)}
|
||
>
|
||
<option value="" ?selected=${!this._triggerAttribute}>${t("use_entity_state", L)}</option>
|
||
${this._suggestedAttributes.map(
|
||
(attr) => html`<option value=${attr} ?selected=${attr === this._triggerAttribute}>${attr} ★</option>`
|
||
)}
|
||
${this._availableAttributes
|
||
.filter((a) => !this._suggestedAttributes.includes(a.name))
|
||
.map(
|
||
(a) => html`<option value=${a.name} ?selected=${a.name === this._triggerAttribute}>${a.name}${a.numeric ? "" : " (non-numeric)"}</option>`
|
||
)}
|
||
</select>
|
||
</div>
|
||
`
|
||
: html`
|
||
<ms-textfield
|
||
label="${t("attribute_optional", L)}"
|
||
.value=${this._triggerAttribute}
|
||
@input=${(e: Event) => (this._triggerAttribute = (e.target as HTMLInputElement).value)}
|
||
></ms-textfield>
|
||
`
|
||
}
|
||
${this._renderTriggerTypeFields()}
|
||
${this._renderTriggerLiveHint()}
|
||
`}
|
||
<label>
|
||
<input
|
||
type="checkbox"
|
||
.checked=${this._autoCompleteOnRecovery}
|
||
@change=${(e: Event) => (this._autoCompleteOnRecovery = (e.target as HTMLInputElement).checked)}
|
||
/>
|
||
${t("auto_complete_on_recovery", L)}
|
||
</label>
|
||
<div class="field-help">${t("auto_complete_on_recovery_help", L)}</div>
|
||
<ms-textfield
|
||
label="${t("safety_interval", L)}"
|
||
type="number"
|
||
.value=${this._intervalDays}
|
||
@input=${(e: Event) => (this._intervalDays = (e.target as HTMLInputElement).value)}
|
||
></ms-textfield>
|
||
${this._intervalDays ? this._renderUnitSelect() : nothing}
|
||
`;
|
||
}
|
||
|
||
/** Immutably patch one compound condition and trigger a re-render. */
|
||
private _patchCondition(index: number, patch: Partial<CompoundConditionDraft>): void {
|
||
this._compoundConditions = this._compoundConditions.map((c, i) =>
|
||
i === index ? { ...c, ...patch } : c
|
||
);
|
||
}
|
||
|
||
private _addCondition(): void {
|
||
this._compoundConditions = [...this._compoundConditions, emptyCondition()];
|
||
}
|
||
|
||
private _removeCondition(index: number): void {
|
||
this._compoundConditions = this._compoundConditions.filter((_, i) => i !== index);
|
||
}
|
||
|
||
/** Compound trigger editor: AND/OR logic + a list of inline conditions. */
|
||
private _renderCompoundEditor() {
|
||
const L = this._lang;
|
||
return html`
|
||
<div class="select-row">
|
||
<label>${t("compound_logic", L)}</label>
|
||
<select
|
||
.value=${this._compoundLogic}
|
||
@change=${(e: Event) => (this._compoundLogic = (e.target as HTMLSelectElement).value as "AND" | "OR")}
|
||
>
|
||
<option value="AND" ?selected=${this._compoundLogic === "AND"}>${t("compound_logic_and", L)}</option>
|
||
<option value="OR" ?selected=${this._compoundLogic === "OR"}>${t("compound_logic_or", L)}</option>
|
||
</select>
|
||
</div>
|
||
<div class="field-help">${t("compound_help", L)}</div>
|
||
${this._compoundConditions.length === 0
|
||
? html`<div class="field-help">${t("compound_no_conditions", L)}</div>`
|
||
: this._compoundConditions.map((c, i) => this._renderCondition(c, i))}
|
||
<button type="button" class="secondary-btn" @click=${() => this._addCondition()}>
|
||
+ ${t("compound_add_condition", L)}
|
||
</button>
|
||
`;
|
||
}
|
||
|
||
/** One compound condition row: entity + sub-type + type-specific params. */
|
||
private _renderCondition(c: CompoundConditionDraft, i: number) {
|
||
const L = this._lang;
|
||
const num = i + 1;
|
||
return html`
|
||
<div class="compound-condition">
|
||
<div class="compound-condition-head">
|
||
<span class="compound-condition-title">${t("compound_condition", L)} ${num}</span>
|
||
<button
|
||
type="button"
|
||
class="icon-btn"
|
||
title="${t("compound_remove_condition", L)}"
|
||
@click=${() => this._removeCondition(i)}
|
||
>✕</button>
|
||
</div>
|
||
<ms-textfield
|
||
label="${t("entity_id", L)} (${t("comma_separated", L)})"
|
||
.value=${c.entityIds}
|
||
@input=${(e: Event) => this._patchCondition(i, { entityIds: (e.target as HTMLInputElement).value })}
|
||
></ms-textfield>
|
||
<div class="select-row">
|
||
<label>${t("trigger_type", L)}</label>
|
||
<select
|
||
.value=${c.type}
|
||
@change=${(e: Event) => this._patchCondition(i, { type: (e.target as HTMLSelectElement).value })}
|
||
>
|
||
${TRIGGER_TYPE_KEYS.map(
|
||
(key) => html`<option value=${key} ?selected=${key === c.type}>${t(key, L)}</option>`
|
||
)}
|
||
</select>
|
||
</div>
|
||
${this._renderConditionTypeFields(c, i)}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
/** Type-specific inputs for a single compound condition (mirrors the flat
|
||
* per-type fields, bound to the condition draft). */
|
||
private _renderConditionTypeFields(c: CompoundConditionDraft, i: number) {
|
||
const L = this._lang;
|
||
if (c.type === "threshold") {
|
||
return html`
|
||
<ms-textfield label="${t("trigger_above", L)}" type="number" .value=${c.above}
|
||
@input=${(e: Event) => this._patchCondition(i, { above: (e.target as HTMLInputElement).value })}></ms-textfield>
|
||
<ms-textfield label="${t("trigger_below", L)}" type="number" .value=${c.below}
|
||
@input=${(e: Event) => this._patchCondition(i, { below: (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 === "counter") {
|
||
return html`
|
||
<ms-textfield label="${t("target_value", L)}" type="number" .value=${c.targetValue}
|
||
@input=${(e: Event) => this._patchCondition(i, { targetValue: (e.target as HTMLInputElement).value })}></ms-textfield>
|
||
<label>
|
||
<input type="checkbox" .checked=${c.deltaMode}
|
||
@change=${(e: Event) => this._patchCondition(i, { deltaMode: (e.target as HTMLInputElement).checked })} />
|
||
${t("delta_mode", L)}
|
||
</label>
|
||
`;
|
||
}
|
||
if (c.type === "state_change") {
|
||
return html`
|
||
<ms-textfield label="${t("from_state_optional", L)}" .value=${c.fromState}
|
||
@input=${(e: Event) => this._patchCondition(i, { fromState: (e.target as HTMLInputElement).value })}></ms-textfield>
|
||
<ms-textfield label="${t("to_state_optional", L)}" .value=${c.toState}
|
||
@input=${(e: Event) => this._patchCondition(i, { toState: (e.target as HTMLInputElement).value })}></ms-textfield>
|
||
<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>
|
||
`;
|
||
}
|
||
if (c.type === "runtime") {
|
||
return html`
|
||
<ms-textfield label="${t("runtime_hours", L)}" type="number" .value=${c.runtimeHours}
|
||
@input=${(e: Event) => this._patchCondition(i, { runtimeHours: (e.target as HTMLInputElement).value })}></ms-textfield>
|
||
<ms-textfield label="${t("runtime_on_states", L)}" placeholder="on" .value=${c.onStates}
|
||
@input=${(e: Event) => this._patchCondition(i, { onStates: (e.target as HTMLInputElement).value })}></ms-textfield>
|
||
`;
|
||
}
|
||
return nothing;
|
||
}
|
||
|
||
/** Shared interval-unit dropdown (DRY: time-based interval + sensor safety
|
||
* interval). Bound to _intervalUnit; options localized via unit_* keys. */
|
||
private _renderUnitSelect() {
|
||
const L = this._lang;
|
||
return html`
|
||
<div class="select-row">
|
||
<label>${t("interval_unit", L)}</label>
|
||
<select
|
||
.value=${this._intervalUnit}
|
||
@change=${(e: Event) => (this._intervalUnit = (e.target as HTMLSelectElement).value)}
|
||
>
|
||
${["days", "weeks", "months", "years"].map(
|
||
(u) => html`<option value=${u} ?selected=${u === this._intervalUnit}>${t("unit_" + u, L)}</option>`
|
||
)}
|
||
</select>
|
||
</div>`;
|
||
}
|
||
|
||
private _toggleWeekday(i: number): void {
|
||
this._weekdays = this._weekdays.includes(i)
|
||
? this._weekdays.filter((d) => d !== i)
|
||
: [...this._weekdays, i];
|
||
}
|
||
|
||
/** The draft schedule in engine (Schedule.to_dict) form — MIRRORS the
|
||
* _save mapping; keep both in sync when adding schedule fields. Null =
|
||
* nothing to preview (manual, or trigger-only without an interval). */
|
||
private _previewScheduleDict(): Record<string, unknown> | null {
|
||
if (this._scheduleType === "one_time") {
|
||
return this._dueDate ? { kind: "one_time", due_date: this._dueDate } : null;
|
||
}
|
||
if (CALENDAR_KINDS.includes(this._scheduleType)) {
|
||
return { ...this._buildSchedule(), ...this._recurrenceExtras() };
|
||
}
|
||
const every = parseInt(this._intervalDays, 10);
|
||
if (this._scheduleType === "manual" || !every || every <= 0) return null;
|
||
return {
|
||
kind: "interval",
|
||
every,
|
||
unit: this._intervalUnit,
|
||
anchor: this._intervalAnchor,
|
||
...this._recurrenceExtras(),
|
||
};
|
||
}
|
||
|
||
private static readonly _PREVIEW_RELEVANT = new Set([
|
||
"_open", "_scheduleType", "_intervalDays", "_intervalUnit", "_intervalAnchor",
|
||
"_dueDate", "_weekdays", "_nth", "_nthWeekday", "_domDay", "_domLastDay",
|
||
"_domBusiness", "_calOffset", "_seasonMonths", "_endsMode", "_endsCount",
|
||
"_endsUntil", "_lastPerformed",
|
||
]);
|
||
|
||
protected updated(changed: Map<PropertyKey, unknown>): void {
|
||
super.updated?.(changed);
|
||
for (const key of changed.keys()) {
|
||
if (MaintenanceTaskDialog._PREVIEW_RELEVANT.has(String(key))) {
|
||
this._schedulePreviewRefresh();
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
private _schedulePreviewRefresh(): void {
|
||
if (this._previewTimer) clearTimeout(this._previewTimer);
|
||
this._previewTimer = setTimeout(() => void this._fetchSchedulePreview(), 300);
|
||
}
|
||
|
||
private async _fetchSchedulePreview(): Promise<void> {
|
||
const sched = this._open ? this._previewScheduleDict() : null;
|
||
if (!sched) {
|
||
this._schedulePreview = [];
|
||
this._schedulePreviewEnded = false;
|
||
return;
|
||
}
|
||
const seq = ++this._previewSeq;
|
||
try {
|
||
const res = await this.hass.connection.sendMessagePromise<{
|
||
occurrences: string[];
|
||
series_ended: boolean;
|
||
}>({
|
||
type: "maintenance_supporter/schedule/preview",
|
||
schedule: sched,
|
||
...(this._lastPerformed ? { last_performed: this._lastPerformed } : {}),
|
||
});
|
||
if (seq !== this._previewSeq) return; // a newer edit superseded this
|
||
this._schedulePreview = res.occurrences || [];
|
||
this._schedulePreviewEnded = !!res.series_ended;
|
||
} catch {
|
||
// Transient WS error — keep the last preview instead of flickering.
|
||
}
|
||
}
|
||
|
||
private _renderSchedulePreview() {
|
||
if (this._schedulePreview.length === 0) return nothing;
|
||
const L = this._lang;
|
||
const time = this.scheduleTimeEnabled && this._scheduleTime ? ` ${this._scheduleTime}` : "";
|
||
const chips = this._schedulePreview
|
||
.map((iso, i) => {
|
||
const js = new Date(`${iso}T12:00:00`).getDay(); // 0=Sun
|
||
const wd = weekdayName(js === 0 ? 6 : js - 1, L, "short");
|
||
return `${wd} ${formatDate(iso, L)}${i === 0 ? time : ""}`;
|
||
})
|
||
.join(" · ");
|
||
const onTime =
|
||
this._scheduleType === "time_based" && this._intervalAnchor === "completion"
|
||
? html`<div class="field-help">${t("schedule_preview_ontime", L)}</div>`
|
||
: nothing;
|
||
return html`
|
||
<div class="trigger-live-hint schedule-preview">
|
||
${t("schedule_preview_title", L)}: ${chips}${this._schedulePreviewEnded
|
||
? html` <span class="field-help">${t("schedule_preview_ends", L)}</span>`
|
||
: nothing}
|
||
${onTime}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
/** Build the nested `schedule` object for the selected calendar kind. */
|
||
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));
|
||
return schedule;
|
||
};
|
||
if (this._scheduleType === "weekdays") {
|
||
return withOffset({ kind: "weekdays", weekdays: [...this._weekdays].sort((a, b) => a - b) });
|
||
}
|
||
if (this._scheduleType === "nth_weekday") {
|
||
return withOffset({
|
||
kind: "nth_weekday",
|
||
nth: parseInt(this._nth, 10),
|
||
weekday: parseInt(this._nthWeekday, 10),
|
||
});
|
||
}
|
||
// (#83) "last day" wins over the day number; business rolls weekends back.
|
||
const schedule: Record<string, unknown> = {
|
||
kind: "day_of_month",
|
||
day: this._domLastDay ? -1 : (parseInt(this._domDay, 10) || 1),
|
||
};
|
||
if (this._domBusiness) schedule.business = true;
|
||
return withOffset(schedule);
|
||
}
|
||
|
||
/** The season/finite-series extras to attach to a recurring schedule. Empty
|
||
* values are omitted, so a clean schedule stays clean (and, since the sent
|
||
* schedule is authoritative, an omitted extra clears a previously-set one). */
|
||
private _recurrenceExtras(): { season_months?: number[]; ends?: { count?: number; until?: string } } {
|
||
const extras: { season_months?: number[]; ends?: { count?: number; until?: string } } = {};
|
||
if (this._seasonMonths.length) extras.season_months = [...this._seasonMonths].sort((a, b) => a - b);
|
||
if (this._endsMode === "count") {
|
||
const n = parseInt(this._endsCount, 10);
|
||
if (n >= 1) extras.ends = { count: n };
|
||
} else if (this._endsMode === "until" && this._endsUntil) {
|
||
extras.ends = { until: this._endsUntil };
|
||
}
|
||
return extras;
|
||
}
|
||
|
||
private _toggleSeasonMonth(m: number): void {
|
||
this._seasonMonths = this._seasonMonths.includes(m)
|
||
? this._seasonMonths.filter((x) => x !== m)
|
||
: [...this._seasonMonths, m];
|
||
}
|
||
|
||
/** Seasonal window + finite-series end — shown for recurring (interval +
|
||
* calendar) kinds, where both apply. */
|
||
private _renderRecurrenceExtras() {
|
||
const L = this._lang;
|
||
const recurring = this._scheduleType === "time_based" || CALENDAR_KINDS.includes(this._scheduleType);
|
||
if (!recurring) return nothing;
|
||
const months = monthNames(L);
|
||
return html`
|
||
<label class="field-label">${t("season_window_label", L)}</label>
|
||
<div class="field-help">${t("season_window_hint", L)}</div>
|
||
<div class="weekday-chips season-chips">
|
||
${months.map((name, i) => html`
|
||
<button
|
||
type="button"
|
||
class="season-chip ${this._seasonMonths.includes(i + 1) ? "selected" : ""}"
|
||
@click=${() => this._toggleSeasonMonth(i + 1)}
|
||
>${name}</button>`)}
|
||
</div>
|
||
|
||
<label class="field-label">${t("series_end_label", L)}</label>
|
||
<div class="select-row">
|
||
<select .value=${this._endsMode}
|
||
@change=${(e: Event) => (this._endsMode = (e.target as HTMLSelectElement).value as "never" | "count" | "until")}>
|
||
<option value="never" ?selected=${this._endsMode === "never"}>${t("series_end_never", L)}</option>
|
||
<option value="count" ?selected=${this._endsMode === "count"}>${t("series_end_after_count", L)}</option>
|
||
<option value="until" ?selected=${this._endsMode === "until"}>${t("series_end_until", L)}</option>
|
||
</select>
|
||
</div>
|
||
${this._endsMode === "count" ? html`
|
||
<ms-textfield
|
||
label="${t("series_end_count_label", L)}"
|
||
type="number" min="1"
|
||
.value=${this._endsCount}
|
||
@input=${(e: Event) => (this._endsCount = (e.target as HTMLInputElement).value)}
|
||
></ms-textfield>` : nothing}
|
||
${this._endsMode === "until" ? html`
|
||
<ms-textfield
|
||
label="${t("series_end_until_label", L)}"
|
||
type="date"
|
||
.value=${this._endsUntil}
|
||
@input=${(e: Event) => (this._endsUntil = (e.target as HTMLInputElement).value)}
|
||
></ms-textfield>` : nothing}
|
||
`;
|
||
}
|
||
|
||
/** Per-kind field groups for the calendar recurrence kinds. */
|
||
private _renderCalendarFields() {
|
||
const L = this._lang;
|
||
const days = weekdayNames(L);
|
||
if (this._scheduleType === "weekdays") {
|
||
return html`
|
||
<label class="field-label">${t("recurrence_on_days", L)}</label>
|
||
<div class="weekday-chips">
|
||
${days.map((name, i) => html`
|
||
<button
|
||
type="button"
|
||
class="weekday-chip ${this._weekdays.includes(i) ? "selected" : ""}"
|
||
@click=${() => this._toggleWeekday(i)}
|
||
>${name}</button>`)}
|
||
</div>
|
||
${this._renderCalOffsetField()}`;
|
||
}
|
||
if (this._scheduleType === "nth_weekday") {
|
||
const nths: Array<[string, string]> = [
|
||
["1", t("ord_1", L)], ["2", t("ord_2", L)], ["3", t("ord_3", L)],
|
||
["4", t("ord_4", L)], ["5", t("ord_5", L)], ["-1", t("ord_last", L)],
|
||
];
|
||
return html`
|
||
<div class="select-row">
|
||
<label>${t("recurrence_occurrence", L)}</label>
|
||
<select .value=${this._nth} @change=${(e: Event) => (this._nth = (e.target as HTMLSelectElement).value)}>
|
||
${nths.map(([v, lbl]) => html`<option value=${v} ?selected=${v === this._nth}>${lbl}</option>`)}
|
||
</select>
|
||
</div>
|
||
<div class="select-row">
|
||
<label>${t("recurrence_weekday", L)}</label>
|
||
<select .value=${this._nthWeekday} @change=${(e: Event) => (this._nthWeekday = (e.target as HTMLSelectElement).value)}>
|
||
${days.map((name, i) => html`<option value=${String(i)} ?selected=${String(i) === this._nthWeekday}>${name}</option>`)}
|
||
</select>
|
||
</div>
|
||
${this._renderCalOffsetField()}`;
|
||
}
|
||
if (this._scheduleType === "day_of_month") {
|
||
return html`
|
||
${this._domLastDay ? nothing : html`
|
||
<ms-textfield
|
||
label="${t("recurrence_day", L)}"
|
||
type="number"
|
||
min="1"
|
||
max="31"
|
||
.value=${this._domDay}
|
||
@input=${(e: Event) => (this._domDay = (e.target as HTMLInputElement).value)}
|
||
></ms-textfield>`}
|
||
<label class="checkbox-row">
|
||
<input type="checkbox" .checked=${this._domLastDay}
|
||
@change=${(e: Event) => (this._domLastDay = (e.target as HTMLInputElement).checked)} />
|
||
<span>${t("recurrence_last_day", L)}</span>
|
||
</label>
|
||
<label class="checkbox-row">
|
||
<input type="checkbox" .checked=${this._domBusiness}
|
||
@change=${(e: Event) => (this._domBusiness = (e.target as HTMLInputElement).checked)} />
|
||
<span>${t("recurrence_business_day", L)}</span>
|
||
</label>
|
||
${this._renderCalOffsetField()}`;
|
||
}
|
||
return nothing;
|
||
}
|
||
|
||
/** (#83) ±N-day shift shared by all calendar kinds. */
|
||
private _renderCalOffsetField() {
|
||
const L = this._lang;
|
||
return html`
|
||
<ms-textfield
|
||
label="${t("recurrence_offset", L)}"
|
||
helper="${t("recurrence_offset_help", L)}"
|
||
type="number"
|
||
min="-15"
|
||
max="15"
|
||
.value=${this._calOffset}
|
||
@input=${(e: Event) => (this._calOffset = (e.target as HTMLInputElement).value)}
|
||
></ms-textfield>`;
|
||
}
|
||
|
||
/** Live "what happens next" hint for sensor-based triggers.
|
||
*
|
||
* Reads the bound entity's CURRENT state client-side (the dialog already
|
||
* holds `hass`) and spells out the trigger semantics against it — clearing
|
||
* the most common usage-meter confusion: a delta counter counts from the
|
||
* sensor's current reading (not from zero) and restarts after each
|
||
* completion. Renders nothing when there's no entity/state to read.
|
||
*/
|
||
private _renderTriggerLiveHint() {
|
||
if (this._triggerType === "compound") return nothing;
|
||
const entityId = this._triggerEntityId || this._triggerEntityIds[0];
|
||
if (!entityId || !this.hass?.states) return nothing;
|
||
const st = this.hass.states[entityId];
|
||
if (!st) return nothing;
|
||
const L = this._lang;
|
||
|
||
const unitAttr = st.attributes?.unit_of_measurement;
|
||
const unit = typeof unitAttr === "string" && unitAttr ? ` ${unitAttr}` : "";
|
||
const raw = this._triggerAttribute
|
||
? st.attributes?.[this._triggerAttribute]
|
||
: st.state;
|
||
const num = typeof raw === "number" ? raw : parseFloat(String(raw));
|
||
const hasNum = raw !== "unknown" && raw !== "unavailable" && raw != null && !isNaN(num);
|
||
const fmt = (v: number) => (Number.isInteger(v) ? String(v) : String(Math.round(v * 10) / 10));
|
||
|
||
const parts: string[] = [];
|
||
if (this._triggerType === "threshold") {
|
||
const above = parseFloat(this._triggerAbove);
|
||
const below = parseFloat(this._triggerBelow);
|
||
if (isNaN(above) && isNaN(below)) return nothing;
|
||
if (hasNum) parts.push(t("trigger_hint_now", L).replace("{value}", fmt(num) + unit));
|
||
if (!isNaN(above)) parts.push(t("trigger_hint_above", L).replace("{target}", fmt(above) + unit));
|
||
if (!isNaN(below)) parts.push(t("trigger_hint_below", L).replace("{target}", fmt(below) + unit));
|
||
} else if (this._triggerType === "counter") {
|
||
const target = parseFloat(this._triggerTargetValue);
|
||
if (isNaN(target)) return nothing;
|
||
if (this._triggerDeltaMode) {
|
||
if (this._taskId) {
|
||
// Editing: the baseline is the reading at the last completion (or
|
||
// task creation), not the current value — don't imply otherwise.
|
||
parts.push(t("trigger_hint_counter_delta_edit", L).replace("{target}", fmt(target) + unit));
|
||
} else if (hasNum) {
|
||
parts.push(
|
||
t("trigger_hint_counter_delta", L)
|
||
.replace("{value}", fmt(num) + unit)
|
||
.replace("{due}", fmt(num + target) + unit)
|
||
.replace("{target}", fmt(target) + unit),
|
||
);
|
||
} else {
|
||
parts.push(t("trigger_hint_counter_delta_edit", L).replace("{target}", fmt(target) + unit));
|
||
}
|
||
} else {
|
||
if (hasNum) parts.push(t("trigger_hint_now", L).replace("{value}", fmt(num) + unit));
|
||
parts.push(t("trigger_hint_counter_abs", L).replace("{target}", fmt(target) + unit));
|
||
}
|
||
} else if (this._triggerType === "runtime") {
|
||
const hours = parseFloat(this._triggerRuntimeHours);
|
||
if (isNaN(hours)) return nothing;
|
||
parts.push(t("trigger_hint_runtime", L).replace("{hours}", fmt(hours)));
|
||
parts.push(t("trigger_hint_state_now", L).replace("{value}", String(st.state)));
|
||
} else if (this._triggerType === "state_change") {
|
||
const n = parseInt(this._triggerTargetChanges, 10) || 1;
|
||
const to = this._triggerToState.trim();
|
||
parts.push(
|
||
(to
|
||
? t("trigger_hint_state_change_to", L).replace("{state}", to)
|
||
: t("trigger_hint_state_change", L)
|
||
).replace("{count}", String(n)),
|
||
);
|
||
parts.push(t("trigger_hint_state_now", L).replace("{value}", String(st.state)));
|
||
}
|
||
if (!parts.length) return nothing;
|
||
return html`<div class="trigger-live-hint">${parts.join(" ")}</div>`;
|
||
}
|
||
|
||
private _renderTriggerTypeFields() {
|
||
const L = this._lang;
|
||
if (this._triggerType === "threshold") {
|
||
return html`
|
||
<ms-textfield
|
||
label="${t("trigger_above", L)}"
|
||
type="number"
|
||
step="any"
|
||
.value=${this._triggerAbove}
|
||
@input=${(e: Event) => (this._triggerAbove = (e.target as HTMLInputElement).value)}
|
||
></ms-textfield>
|
||
<ms-textfield
|
||
label="${t("trigger_below", L)}"
|
||
type="number"
|
||
step="any"
|
||
.value=${this._triggerBelow}
|
||
@input=${(e: Event) => (this._triggerBelow = (e.target as HTMLInputElement).value)}
|
||
></ms-textfield>
|
||
<ms-textfield
|
||
label="${t("for_at_least_minutes", L)}"
|
||
type="number"
|
||
.value=${this._triggerForMinutes}
|
||
@input=${(e: Event) => (this._triggerForMinutes = (e.target as HTMLInputElement).value)}
|
||
></ms-textfield>
|
||
`;
|
||
}
|
||
if (this._triggerType === "counter") {
|
||
return html`
|
||
<ms-textfield
|
||
label="${t("target_value", L)}"
|
||
type="number"
|
||
step="any"
|
||
.value=${this._triggerTargetValue}
|
||
@input=${(e: Event) => (this._triggerTargetValue = (e.target as HTMLInputElement).value)}
|
||
></ms-textfield>
|
||
<label>
|
||
<input
|
||
type="checkbox"
|
||
.checked=${this._triggerDeltaMode}
|
||
@change=${(e: Event) => (this._triggerDeltaMode = (e.target as HTMLInputElement).checked)}
|
||
/>
|
||
${t("delta_mode", L)}
|
||
</label>
|
||
${this._triggerDeltaMode
|
||
? html`
|
||
<ms-textfield
|
||
label="${t("baseline_start_value", L)}"
|
||
type="number"
|
||
step="any"
|
||
.value=${this._triggerBaselineValue}
|
||
@input=${(e: Event) => (this._triggerBaselineValue = (e.target as HTMLInputElement).value)}
|
||
></ms-textfield>
|
||
<div class="field-help">
|
||
${this._taskId ? t("baseline_start_help_edit", L) : t("baseline_start_help", L)}
|
||
${this._taskId && this._liveBaselineValue != null
|
||
? html`<div class="baseline-effective">
|
||
${t("baseline_current_effective", L).replace(
|
||
"{value}",
|
||
String(this._liveBaselineValue),
|
||
)}
|
||
</div>`
|
||
: nothing}
|
||
</div>
|
||
`
|
||
: nothing}
|
||
`;
|
||
}
|
||
if (this._triggerType === "state_change") {
|
||
return html`
|
||
<ms-textfield
|
||
label="${t("from_state_optional", L)}"
|
||
.value=${this._triggerFromState}
|
||
@input=${(e: Event) => (this._triggerFromState = (e.target as HTMLInputElement).value)}
|
||
></ms-textfield>
|
||
<div class="field-help">${t("state_value_help", L)}</div>
|
||
<ms-textfield
|
||
label="${t("to_state_optional", L)}"
|
||
.value=${this._triggerToState}
|
||
@input=${(e: Event) => (this._triggerToState = (e.target as HTMLInputElement).value)}
|
||
></ms-textfield>
|
||
<ms-textfield
|
||
label="${t("target_changes", L)}"
|
||
type="number"
|
||
min="1"
|
||
.value=${this._triggerTargetChanges}
|
||
@input=${(e: Event) => (this._triggerTargetChanges = (e.target as HTMLInputElement).value)}
|
||
></ms-textfield>
|
||
<div class="field-help">${t("target_changes_help", L)}</div>
|
||
`;
|
||
}
|
||
if (this._triggerType === "runtime") {
|
||
return html`
|
||
<ms-textfield
|
||
label="${t("runtime_hours", L)}"
|
||
type="number"
|
||
step="1"
|
||
.value=${this._triggerRuntimeHours}
|
||
@input=${(e: Event) => (this._triggerRuntimeHours = (e.target as HTMLInputElement).value)}
|
||
></ms-textfield>
|
||
<ms-textfield
|
||
label="${t("runtime_on_states", L)}"
|
||
placeholder="on"
|
||
.value=${this._triggerOnStates}
|
||
@input=${(e: Event) => (this._triggerOnStates = (e.target as HTMLInputElement).value)}
|
||
></ms-textfield>
|
||
<div class="field-help">${t("runtime_on_states_help", L)}</div>
|
||
`;
|
||
}
|
||
return nothing;
|
||
}
|
||
|
||
render() {
|
||
if (!this._open) return html``;
|
||
const L = this._lang;
|
||
const title = this._taskId ? t("edit_task", L) : t("new_task", L);
|
||
return html`
|
||
<ha-dialog open @closed=${this._close}>
|
||
<div class="dialog-title">${title}</div>
|
||
<div class="content">
|
||
${this._error ? html`<div class="error">${this._error}</div>` : nothing}
|
||
${this._objectChoices.length > 0 ? html`
|
||
<div class="select-row">
|
||
<label>${t("object", L)}</label>
|
||
<select
|
||
.value=${this._entryId}
|
||
@change=${(e: Event) => {
|
||
this._entryId = (e.target as HTMLSelectElement).value;
|
||
this._consumesParts = {};
|
||
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).
|
||
this._loadForeignPools();
|
||
}}
|
||
>
|
||
${this._objectChoices.map(
|
||
(o) => html`<option value=${o.entry_id} ?selected=${o.entry_id === this._entryId}>${o.name}</option>`
|
||
)}
|
||
</select>
|
||
</div>
|
||
` : nothing}
|
||
<ms-textfield
|
||
label="${t("task_name", L)}"
|
||
required
|
||
.value=${this._name}
|
||
@input=${(e: Event) => (this._name = (e.target as HTMLInputElement).value)}
|
||
></ms-textfield>
|
||
<div class="select-row">
|
||
<label>${t("maintenance_type", L)}</label>
|
||
<select
|
||
.value=${this._type}
|
||
@change=${(e: Event) => (this._type = (e.target as HTMLSelectElement).value)}
|
||
>
|
||
${MAINTENANCE_TYPE_KEYS.map(
|
||
(key) => html`<option value=${key} ?selected=${key === this._type}>${t(key, L)}</option>`
|
||
)}
|
||
</select>
|
||
</div>
|
||
${this._type === "reading"
|
||
? html`
|
||
<ms-textfield
|
||
label="${t("reading_unit_label", L)}"
|
||
.value=${this._readingUnit}
|
||
@input=${(e: Event) => (this._readingUnit = (e.target as HTMLInputElement).value)}
|
||
></ms-textfield>
|
||
<div class="field-help">${t("reading_unit_help", L)}</div>
|
||
`
|
||
: nothing}
|
||
${this._partsLoadFailed
|
||
? html`<div class="field-help parts-load-failed">${t("parts_load_failed", L)}</div>`
|
||
: nothing}
|
||
${this.parts.length || this._foreignOwners.length
|
||
? html`
|
||
<div class="field">
|
||
<label>${t("consumes_parts_label", L)}</label>
|
||
${this.parts.map((part) => this._renderConsumesRow(part))}
|
||
${this._foreignOwners.length
|
||
? html`
|
||
<details class="shared-pools" ?open=${this._hasForeignPick}>
|
||
<summary>${t("shared_parts_other_objects", L)}</summary>
|
||
<div class="field-help">${t("shared_parts_help", L)}</div>
|
||
${this._foreignOwners.map(
|
||
(owner) => html`
|
||
<div class="shared-pool-owner">${owner.name}</div>
|
||
${owner.parts.map((part) =>
|
||
this._renderConsumesRow(part, owner.entry_id),
|
||
)}
|
||
`,
|
||
)}
|
||
</details>
|
||
`
|
||
: nothing}
|
||
</div>
|
||
`
|
||
: nothing}
|
||
<div class="select-row">
|
||
<label>${t("priority", L)}</label>
|
||
<select
|
||
.value=${this._priority}
|
||
@change=${(e: Event) => (this._priority = (e.target as HTMLSelectElement).value)}
|
||
>
|
||
${PRIORITY_KEYS.map(
|
||
(key) => html`<option value=${key} ?selected=${key === this._priority}>${t("priority_" + key, L)}</option>`
|
||
)}
|
||
</select>
|
||
</div>
|
||
<div class="field">
|
||
<label>${t("labels", L)}</label>
|
||
<input
|
||
type="text"
|
||
.value=${this._labels}
|
||
placeholder="${t("labels_placeholder", L)}"
|
||
@input=${(e: Event) => (this._labels = (e.target as HTMLInputElement).value)}
|
||
/>
|
||
<div class="field-help">${t("labels_help", L)}</div>
|
||
</div>
|
||
<div class="select-row">
|
||
<label>${t("schedule_type", L)}</label>
|
||
<select
|
||
.value=${this._scheduleType}
|
||
@change=${(e: Event) => (this._scheduleType = (e.target as HTMLSelectElement).value)}
|
||
>
|
||
${SCHEDULE_TYPE_KEYS.map(
|
||
(key) => html`<option value=${key} ?selected=${key === this._scheduleType}>${t(key, L)}</option>`
|
||
)}
|
||
</select>
|
||
</div>
|
||
${this._scheduleType === "time_based"
|
||
? html`
|
||
<ms-textfield
|
||
label="${t("interval_value", L)}"
|
||
type="number"
|
||
.value=${this._intervalDays}
|
||
@input=${(e: Event) => (this._intervalDays = (e.target as HTMLInputElement).value)}
|
||
></ms-textfield>
|
||
${this._renderUnitSelect()}
|
||
<div class="select-row">
|
||
<label>${t("interval_anchor", L)}</label>
|
||
<select
|
||
.value=${this._intervalAnchor}
|
||
@change=${(e: Event) => (this._intervalAnchor = (e.target as HTMLSelectElement).value as "completion" | "planned")}
|
||
>
|
||
<option value="completion" ?selected=${this._intervalAnchor === "completion"}>${t("anchor_completion", L)}</option>
|
||
<option value="planned" ?selected=${this._intervalAnchor === "planned"}>${t("anchor_planned", L)}</option>
|
||
</select>
|
||
</div>
|
||
${this.scheduleTimeEnabled ? html`
|
||
<ms-textfield
|
||
label="${t("schedule_time_optional", L)}"
|
||
type="time"
|
||
.value=${this._scheduleTime}
|
||
helper="${t("schedule_time_help", L)}"
|
||
@input=${(e: Event) => (this._scheduleTime = (e.target as HTMLInputElement).value)}
|
||
></ms-textfield>
|
||
` : nothing}
|
||
`
|
||
: nothing}
|
||
${this._renderCalendarFields()}
|
||
${this._scheduleType === "one_time"
|
||
? html`
|
||
<ms-textfield
|
||
label="${t("due_date", L)}"
|
||
type="date"
|
||
.value=${this._dueDate}
|
||
@input=${(e: Event) => (this._dueDate = (e.target as HTMLInputElement).value)}
|
||
></ms-textfield>
|
||
`
|
||
: nothing}
|
||
${this._renderRecurrenceExtras()}
|
||
${this._renderSchedulePreview()}
|
||
<ms-textfield
|
||
label="${t("warning_days", L)}"
|
||
type="number"
|
||
min="0"
|
||
max="365"
|
||
.value=${this._warningDays}
|
||
@input=${(e: Event) => (this._warningDays = (e.target as HTMLInputElement).value)}
|
||
></ms-textfield>
|
||
<ms-textfield
|
||
label="${t("earliest_completion_days", L)}"
|
||
helper="${t("earliest_completion_days_help", L)}"
|
||
type="number"
|
||
.value=${this._earliestCompletionDays}
|
||
@input=${(e: Event) => (this._earliestCompletionDays = (e.target as HTMLInputElement).value)}
|
||
></ms-textfield>
|
||
${this.checklistsEnabled ? html`
|
||
<h3>${t("checklist_steps_optional", L)}</h3>
|
||
<textarea
|
||
id="checklist-textarea"
|
||
class="checklist-textarea"
|
||
rows="5"
|
||
placeholder="${t("checklist_placeholder", L)}"
|
||
.value=${this._checklistText}
|
||
@input=${(e: Event) => (this._checklistText = (e.target as HTMLTextAreaElement).value)}
|
||
></textarea>
|
||
<div class="field-help">${t("checklist_help", L)}</div>
|
||
` : nothing}
|
||
<h3>${t("require_on_completion", L)}</h3>
|
||
<div class="required-completion">
|
||
${REQUIRED_COMPLETION_KEYS.map((field) => html`
|
||
<label class="req-option">
|
||
<input
|
||
type="checkbox"
|
||
.checked=${this._requiredCompletion.includes(field)}
|
||
@change=${(e: Event) => this._toggleRequired(field, (e.target as HTMLInputElement).checked)}
|
||
/>
|
||
<span>${t(REQUIRED_COMPLETION_LABELS[field], L)}</span>
|
||
</label>
|
||
`)}
|
||
</div>
|
||
<ms-textfield
|
||
label="${t("last_performed_optional", L)}"
|
||
type="date"
|
||
.value=${this._lastPerformed}
|
||
@input=${(e: Event) => (this._lastPerformed = (e.target as HTMLInputElement).value)}
|
||
></ms-textfield>
|
||
<div class="select-row">
|
||
<label>${t("responsible_user", L)}</label>
|
||
<select
|
||
.value=${this._responsibleUserId || ""}
|
||
@change=${(e: Event) => {
|
||
const val = (e.target as HTMLSelectElement).value;
|
||
this._responsibleUserId = val || null;
|
||
}}
|
||
>
|
||
<option value="" ?selected=${!this._responsibleUserId}>${t("no_user_assigned", L)}</option>
|
||
${this._availableUsers.map(
|
||
(user) => html`<option value=${user.id} ?selected=${user.id === this._responsibleUserId}>${user.name}</option>`
|
||
)}
|
||
</select>
|
||
</div>
|
||
${this._availableUsers.length >= 2 ? html`
|
||
<div class="field">
|
||
<label>${t("shared_with", L)}</label>
|
||
<div class="field-help">${t("shared_with_help", L)}</div>
|
||
<div class="assignee-pool">
|
||
${this._availableUsers.map((user) => html`
|
||
<label class="pool-item">
|
||
<input type="checkbox"
|
||
.checked=${this._assigneePool.includes(user.id)}
|
||
@change=${() => this._toggleAssignee(user.id)} />
|
||
<span>${user.name}</span>
|
||
</label>`)}
|
||
</div>
|
||
</div>
|
||
${this._assigneePool.length >= 2 ? html`
|
||
<div class="select-row">
|
||
<label>${t("rotation_strategy", L)}</label>
|
||
<select
|
||
.value=${this._rotationStrategy}
|
||
@change=${(e: Event) => (this._rotationStrategy = (e.target as HTMLSelectElement).value)}
|
||
>
|
||
<option value="" ?selected=${!this._rotationStrategy}>${t("rotation_none", L)}</option>
|
||
${["round_robin", "least_completed", "random"].map(
|
||
(key) => html`<option value=${key} ?selected=${key === this._rotationStrategy}>${t("rotation_" + key, L)}</option>`
|
||
)}
|
||
</select>
|
||
</div>` : nothing}
|
||
` : nothing}
|
||
${this._renderTriggerFields()}
|
||
${this._scheduleType === "sensor_based" ? html`
|
||
<ms-textfield
|
||
label="${t("environmental_entity_optional", L)}"
|
||
helper="${t("environmental_entity_helper", L)}"
|
||
.value=${this._environmentalEntity}
|
||
@input=${(e: Event) => (this._environmentalEntity = (e.target as HTMLInputElement).value.trim())}
|
||
></ms-textfield>
|
||
${this._environmentalEntity ? html`
|
||
<ms-textfield
|
||
label="${t("environmental_attribute_optional", L)}"
|
||
.value=${this._environmentalAttribute}
|
||
@input=${(e: Event) => (this._environmentalAttribute = (e.target as HTMLInputElement).value.trim())}
|
||
></ms-textfield>
|
||
` : nothing}
|
||
` : nothing}
|
||
<ms-textfield
|
||
label="${t("notes_optional", L)}"
|
||
.value=${this._notes}
|
||
@input=${(e: Event) => (this._notes = (e.target as HTMLInputElement).value)}
|
||
></ms-textfield>
|
||
<ms-textfield
|
||
label="${t("documentation_url_optional", L)}"
|
||
.value=${this._documentationUrl}
|
||
@input=${(e: Event) => (this._documentationUrl = (e.target as HTMLInputElement).value)}
|
||
></ms-textfield>
|
||
<ha-icon-picker
|
||
.hass=${this.hass}
|
||
label="${t("custom_icon_optional", L)}"
|
||
.value=${this._customIcon}
|
||
@value-changed=${(e: CustomEvent) =>
|
||
(this._customIcon = (e.detail.value as string) || "")}
|
||
></ha-icon-picker>
|
||
${this._availableTags.length > 0
|
||
? html`
|
||
<div class="select-row">
|
||
<label>${t("nfc_tag_id_optional", L)}</label>
|
||
<select
|
||
.value=${this._nfcTagId}
|
||
@change=${(e: Event) => (this._nfcTagId = (e.target as HTMLSelectElement).value)}
|
||
>
|
||
<option value="" ?selected=${!this._nfcTagId}>${t("no_nfc_tag", L)}</option>
|
||
${this._availableTags.map(
|
||
(tag) => html`<option value=${tag.id} ?selected=${tag.id === this._nfcTagId}>${tag.name}</option>`
|
||
)}
|
||
</select>
|
||
<button type="button" class="link-button" @click=${this._loadTags}
|
||
title="${t("nfc_tags_refresh", L)}">↻</button>
|
||
</div>
|
||
`
|
||
: html`
|
||
<ms-textfield
|
||
label="${t("nfc_tag_id_optional", L)}"
|
||
.value=${this._nfcTagId}
|
||
@input=${(e: Event) => (this._nfcTagId = (e.target as HTMLInputElement).value)}
|
||
></ms-textfield>
|
||
<div class="field-help">
|
||
${t("nfc_tags_empty_help", L)}
|
||
<a href="/config/tags">${t("nfc_tags_open_settings", L)}</a>
|
||
·
|
||
<button type="button" class="link-button" @click=${this._loadTags}>
|
||
${t("nfc_tags_refresh", L)}
|
||
</button>
|
||
</div>
|
||
`
|
||
}
|
||
<label class="toggle-row">
|
||
<input
|
||
type="checkbox"
|
||
.checked=${this._enabled}
|
||
@change=${(e: Event) => (this._enabled = (e.target as HTMLInputElement).checked)}
|
||
/>
|
||
${t("task_enabled", L)}
|
||
</label>
|
||
${this._renderCompletionActionsSection(L)}
|
||
</div>
|
||
<div class="dialog-actions">
|
||
<ha-button appearance="plain" @click=${this._close}>${t("cancel", L)}</ha-button>
|
||
<ha-button
|
||
@click=${this._save}
|
||
.disabled=${this._loading || !this._name.trim()}
|
||
>
|
||
${this._loading ? t("saving", L) : t("save", L)}
|
||
</ha-button>
|
||
</div>
|
||
</ha-dialog>
|
||
`;
|
||
}
|
||
|
||
static styles = css`
|
||
.dialog-title {
|
||
font-size: 18px;
|
||
font-weight: 500;
|
||
padding-bottom: 12px;
|
||
}
|
||
/* v1.3.0: completion-action sections */
|
||
.ca-section {
|
||
border: 1px solid var(--divider-color);
|
||
border-radius: 6px;
|
||
padding: 8px 12px;
|
||
margin-top: 8px;
|
||
}
|
||
.ca-section > summary {
|
||
cursor: pointer;
|
||
font-weight: 500;
|
||
}
|
||
.ca-section ms-textfield,
|
||
.ca-section ha-entity-picker,
|
||
.ca-section ha-service-picker,
|
||
.ca-section ha-form,
|
||
.ca-section .qc-feedback {
|
||
width: 100%;
|
||
margin-top: 8px;
|
||
display: block;
|
||
}
|
||
.ca-section .qc-feedback {
|
||
padding: 8px;
|
||
border: 1px solid var(--divider-color);
|
||
border-radius: 4px;
|
||
background: var(--card-background-color, #fff);
|
||
color: var(--primary-text-color);
|
||
}
|
||
.ca-test-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
margin-top: 8px;
|
||
}
|
||
.ca-test-ok { color: var(--success-color, #4caf50); font-size: 13px; }
|
||
.ca-test-error { color: var(--error-color, #f44336); font-size: 13px; font-weight: 500; }
|
||
.ca-test-error-block { display: flex; flex-direction: column; gap: 4px; flex: 1; min-width: 0; }
|
||
.ca-test-error-detail {
|
||
font-size: 12px;
|
||
color: var(--secondary-text-color);
|
||
background: rgba(244, 67, 54, 0.08);
|
||
padding: 6px 8px; border-radius: 4px;
|
||
line-height: 1.4;
|
||
word-break: break-word;
|
||
}
|
||
.content {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
min-width: 350px;
|
||
max-height: 70vh;
|
||
overflow-y: auto;
|
||
}
|
||
@media (max-width: 600px) {
|
||
.content {
|
||
min-width: 0;
|
||
max-height: none;
|
||
}
|
||
}
|
||
.dialog-actions {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 8px;
|
||
padding-top: 16px;
|
||
}
|
||
ms-textfield {
|
||
display: block;
|
||
}
|
||
.field-label {
|
||
font-size: 12px;
|
||
color: var(--secondary-text-color);
|
||
}
|
||
.checklist-textarea {
|
||
width: 100%;
|
||
min-height: 88px;
|
||
padding: 8px;
|
||
font-family: inherit;
|
||
font-size: 14px;
|
||
border: 1px solid var(--divider-color);
|
||
border-radius: 4px;
|
||
background: var(--card-background-color);
|
||
color: var(--primary-text-color);
|
||
resize: vertical;
|
||
box-sizing: border-box;
|
||
}
|
||
.consumes-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 2px 0;
|
||
}
|
||
.consumes-check {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
flex: 1;
|
||
}
|
||
.consumes-qty {
|
||
width: 64px;
|
||
padding: 4px;
|
||
border: 1px solid var(--divider-color);
|
||
border-radius: 4px;
|
||
background: var(--card-background-color);
|
||
color: var(--primary-text-color);
|
||
}
|
||
/* #111: other objects' pools sit behind a disclosure so the object's OWN
|
||
parts stay the primary list; each group is headed by the owning object's
|
||
name, so which pool a checkbox means is never a guess. */
|
||
.shared-pools {
|
||
margin-top: 6px;
|
||
}
|
||
.shared-pools > summary {
|
||
cursor: pointer;
|
||
padding: 2px 0;
|
||
font-size: 13px;
|
||
color: var(--secondary-text-color);
|
||
}
|
||
.shared-pool-owner {
|
||
margin-top: 6px;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
color: var(--secondary-text-color);
|
||
}
|
||
.field-help {
|
||
font-size: 12px;
|
||
color: var(--secondary-text-color);
|
||
}
|
||
.baseline-effective {
|
||
margin-top: 2px;
|
||
font-weight: 500;
|
||
color: var(--primary-text-color);
|
||
}
|
||
/* Live computed trigger hint — reads the bound sensor and explains what
|
||
happens next. Info-accented so it reads as guidance, not an error. */
|
||
.trigger-live-hint {
|
||
font-size: 12px;
|
||
color: var(--secondary-text-color);
|
||
border-left: 3px solid var(--info-color, #2196f3);
|
||
background: rgba(33, 150, 243, 0.08);
|
||
border-radius: 0 6px 6px 0;
|
||
padding: 6px 10px;
|
||
margin: 4px 0;
|
||
}
|
||
.field-help a,
|
||
.link-button {
|
||
background: none;
|
||
border: 0;
|
||
padding: 0;
|
||
color: var(--primary-color);
|
||
cursor: pointer;
|
||
font: inherit;
|
||
text-decoration: underline;
|
||
}
|
||
.field-help a:hover,
|
||
.link-button:hover {
|
||
text-decoration: none;
|
||
}
|
||
/* Smaller refresh icon-button when shown next to the dropdown. */
|
||
.select-row .link-button {
|
||
margin-left: 8px;
|
||
text-decoration: none;
|
||
font-size: 16px;
|
||
}
|
||
.select-row .link-button:hover {
|
||
color: var(--primary-color);
|
||
opacity: 0.7;
|
||
}
|
||
h3 {
|
||
margin: 8px 0 0;
|
||
font-size: 14px;
|
||
color: var(--primary-color);
|
||
}
|
||
.select-row {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
.assignee-pool {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px 14px;
|
||
margin-top: 4px;
|
||
}
|
||
.checkbox-row {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-size: 13px;
|
||
cursor: pointer;
|
||
margin: 2px 0;
|
||
}
|
||
.checkbox-row input[type="checkbox"] {
|
||
width: 16px;
|
||
height: 16px;
|
||
cursor: pointer;
|
||
}
|
||
.pool-item {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 13px;
|
||
cursor: pointer;
|
||
}
|
||
.pool-item input[type="checkbox"] {
|
||
width: 16px;
|
||
height: 16px;
|
||
cursor: pointer;
|
||
}
|
||
.select-row label {
|
||
font-size: 12px;
|
||
color: var(--secondary-text-color);
|
||
}
|
||
.select-row select {
|
||
padding: 8px;
|
||
border: 1px solid var(--divider-color);
|
||
border-radius: 4px;
|
||
background: var(--card-background-color, #fff);
|
||
color: var(--primary-text-color);
|
||
font-size: 14px;
|
||
}
|
||
.field-label {
|
||
font-size: 12px;
|
||
color: var(--secondary-text-color);
|
||
}
|
||
.weekday-chips {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
}
|
||
.weekday-chip {
|
||
padding: 6px 12px;
|
||
border: 1px solid var(--divider-color);
|
||
border-radius: 16px;
|
||
background: var(--card-background-color, #fff);
|
||
color: var(--primary-text-color);
|
||
font-size: 13px;
|
||
cursor: pointer;
|
||
}
|
||
.weekday-chip.selected {
|
||
background: var(--primary-color, #03a9f4);
|
||
color: var(--text-primary-color, #fff);
|
||
border-color: var(--primary-color, #03a9f4);
|
||
}
|
||
.season-chip {
|
||
padding: 6px 10px;
|
||
border: 1px solid var(--divider-color);
|
||
border-radius: 16px;
|
||
background: var(--card-background-color, #fff);
|
||
color: var(--primary-text-color);
|
||
font-size: 13px;
|
||
cursor: pointer;
|
||
}
|
||
.season-chip.selected {
|
||
background: var(--primary-color, #03a9f4);
|
||
color: var(--text-primary-color, #fff);
|
||
border-color: var(--primary-color, #03a9f4);
|
||
}
|
||
.error {
|
||
color: var(--error-color, #f44336);
|
||
font-size: 13px;
|
||
}
|
||
.toggle-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-size: 14px;
|
||
cursor: pointer;
|
||
}
|
||
`;
|
||
}
|
||
|
||
if (!customElements.get("maintenance-task-dialog")) {
|
||
customElements.define("maintenance-task-dialog", MaintenanceTaskDialog);
|
||
}
|