429 files

This commit is contained in:
Home Assistant Version Control committed 2026-08-30 02:43:31 +00:00
1 parent 472c19bceb
commit 963cce81a7
566 files changed
+50311 -47616

No files matched your search

@@ -244,6 +244,7 @@ export class MaintenanceTaskDialog extends LitElement {
// NFC
@state() private _lastPerformed = "";
@state() private _nfcTagId = "";
@state() private _requireTagScan = false;
// 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,
@@ -263,12 +264,16 @@ export class MaintenanceTaskDialog extends LitElement {
// Task phases (#139): editable defs + the cycle order. The v1 editor keeps
// each phase to name + checklist + at most ONE own-object part (the mower
// "14 new blades" case); richer per-phase config exists at the WS level.
// `carry` holds def fields the editor has no form for (notes,
// required_completion_fields, future keys) and `extraParts` any links
// beyond the first — both re-merged on save so a no-op edit never wipes
// them (#106 pattern; same passthrough idea as trigger_config's carry).
// `carry` holds def fields the editor has no form for (notes, future
// keys) and `extraParts` any links beyond the first — both re-merged on
// save so a no-op edit never wipes them (#106 pattern; same passthrough
// idea as trigger_config's carry). Required completion fields are a real
// form now (#139 follow-up): `reqOverride` distinguishes "inherit the
// task's list" (off) from an explicit per-phase list — including the
// meaningful EMPTY override "this phase demands nothing".
@state() private _phaseDefs: Array<{
id: string; name: string; checklistText: string; partId: string; partQty: string;
reqOverride: boolean; reqFields: string[];
extraParts: TaskPartLink[]; carry: Record<string, unknown>;
}> = [];
@state() private _phaseSeq: string[] = [];
@@ -348,6 +353,11 @@ export class MaintenanceTaskDialog extends LitElement {
this._entryId = entryId;
this._taskId = task.id;
this._error = "";
// The object picker belongs to CREATE only. openCreate("", objects) left
// the choices behind, so Create → Cancel → Edit showed the dropdown in
// edit mode — and picking another object re-pointed _entryId while
// _taskId kept the old task (audit 2026-08-29).
this._objectChoices = [];
this._name = task.name;
this._type = task.type;
this._scheduleType = task.schedule_type;
@@ -389,6 +399,7 @@ export class MaintenanceTaskDialog extends LitElement {
this._enabled = task.enabled !== false;
this._lastPerformed = task.last_performed || "";
this._nfcTagId = task.nfc_tag_id || "";
this._requireTagScan = !!task.require_tag_scan;
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).
@@ -401,14 +412,28 @@ export class MaintenanceTaskDialog extends LitElement {
this._checklistText = (task.checklist || []).join("\n");
this._phaseDefs = Object.entries(task.phases || {}).map(([id, def]) => {
const { name: _n, checklist: _c, consumes_parts: _p, ...carry } = def as unknown as Record<string, unknown>;
const {
name: _n, checklist: _c, consumes_parts: _p, required_completion_fields: _r,
...carry
} = def as unknown as Record<string, unknown>;
// The editor's single part field is the first OWN link (no entry_id —
// the picker only lists the object's own parts). Every other link,
// shared pools (#111) first among them, rides along whole in
// `extraParts` and is re-emitted on save: hydrating index 0 blindly
// turned a phase whose first link is foreign into an own-part link
// (or lost it) on the next save (audit 2026-08-29).
const links = def.consumes_parts || [];
const ownIdx = links.findIndex((l) => !l.entry_id);
const own = ownIdx >= 0 ? links[ownIdx] : undefined;
return {
id,
name: def.name || id,
checklistText: (def.checklist || []).join("\n"),
partId: def.consumes_parts?.[0]?.part_id || "",
partQty: def.consumes_parts?.[0]?.quantity != null ? String(def.consumes_parts[0].quantity) : "",
extraParts: (def.consumes_parts || []).slice(1),
partId: own?.part_id || "",
partQty: own?.quantity != null ? String(own.quantity) : "",
reqOverride: def.required_completion_fields !== undefined,
reqFields: [...(def.required_completion_fields || [])],
extraParts: links.filter((_, i) => i !== ownIdx).map((l) => ({ ...l })),
carry,
};
});
@@ -525,6 +550,7 @@ export class MaintenanceTaskDialog extends LitElement {
this._enabled = true;
this._lastPerformed = "";
this._nfcTagId = "";
this._requireTagScan = false;
this._readingUnit = "";
this._consumesParts = {};
this._responsibleUserId = null;
@@ -1032,7 +1058,7 @@ export class MaintenanceTaskDialog extends LitElement {
private _addPhaseDef(): void {
const id = this._phaseSlug(`phase-${this._phaseDefs.length + 1}`);
this._phaseDefs = [...this._phaseDefs, { id, name: "", checklistText: "", partId: "", partQty: "", extraParts: [], carry: {} }];
this._phaseDefs = [...this._phaseDefs, { id, name: "", checklistText: "", partId: "", partQty: "", reqOverride: false, reqFields: [], extraParts: [], carry: {} }];
}
private _removePhaseDef(id: string): void {
@@ -1040,7 +1066,10 @@ export class MaintenanceTaskDialog extends LitElement {
this._phaseSeq = this._phaseSeq.filter((s) => s !== id);
}
private _patchPhaseDef(id: string, patch: Partial<{ name: string; checklistText: string; partId: string; partQty: string }>): void {
private _patchPhaseDef(
id: string,
patch: Partial<{ name: string; checklistText: string; partId: string; partQty: string; reqOverride: boolean; reqFields: string[] }>,
): void {
this._phaseDefs = this._phaseDefs.map((d) => (d.id === id ? { ...d, ...patch } : d));
}
@@ -1084,6 +1113,33 @@ export class MaintenanceTaskDialog extends LitElement {
@input=${(e: Event) => this._patchPhaseDef(d.id, { checklistText: (e.target as HTMLTextAreaElement).value })}
></textarea>
` : nothing}
<label class="req-option phase-req-toggle">
<input
type="checkbox"
.checked=${d.reqOverride}
@change=${(e: Event) => this._patchPhaseDef(d.id, { reqOverride: (e.target as HTMLInputElement).checked })}
/>
<span>${t("phase_require_override", L)}</span>
</label>
${d.reqOverride ? html`
<div class="required-completion phase-req-fields">
${REQUIRED_COMPLETION_KEYS.map((field) => html`
<label class="req-option">
<input
type="checkbox"
.checked=${d.reqFields.includes(field)}
@change=${(e: Event) => {
const on = (e.target as HTMLInputElement).checked;
const next = new Set(d.reqFields);
if (on) next.add(field); else next.delete(field);
this._patchPhaseDef(d.id, { reqFields: [...next] });
}}
/>
<span>${t(REQUIRED_COMPLETION_LABELS[field], L)}</span>
</label>
`)}
</div>
` : nothing}
</div>
`)}
<ha-button appearance="plain" @click=${this._addPhaseDef}>
@@ -1196,6 +1252,7 @@ export class MaintenanceTaskDialog extends LitElement {
data.enabled = this._enabled;
data.last_performed = this._lastPerformed || null;
data.nfc_tag_id = this._nfcTagId || null;
data.require_tag_scan = this._requireTagScan;
data.reading_unit = this._readingUnit.trim() || null;
// Task phases (#139): always sent — null clears a removed cycle.
{
@@ -1210,8 +1267,19 @@ export class MaintenanceTaskDialog extends LitElement {
const qty = parseFloat(d.partQty);
links.push({ part_id: d.partId, quantity: Number.isFinite(qty) && qty > 0 ? qty : 1 });
}
links.push(...d.extraParts);
// entry_id travels only when present (a shared pool, #111) — an
// own-part link stays byte-identical to what shipped before.
for (const l of d.extraParts) {
links.push(
l.entry_id
? { part_id: l.part_id, quantity: l.quantity, entry_id: l.entry_id }
: { part_id: l.part_id, quantity: l.quantity },
);
}
if (links.length) def.consumes_parts = links;
// Override on → send the list, EMPTY included ("demands nothing");
// off → omit the key so the task-level list falls through.
if (d.reqOverride) def.required_completion_fields = [...d.reqFields];
defs[d.id] = def;
}
const seq = this._phaseSeq.filter((id) => id in defs);
@@ -2432,7 +2500,7 @@ export class MaintenanceTaskDialog extends LitElement {
<div class="dialog-title">${title}</div>
<div class="content">
${this._error ? html`<div class="error">${this._error}</div>` : nothing}
${this._objectChoices.length > 0 ? html`
${this._taskId === null && this._objectChoices.length > 0 ? html`
<div class="select-row">
<label>${t("object", L)}</label>
<select
@@ -2703,6 +2771,9 @@ export class MaintenanceTaskDialog extends LitElement {
${this._renderAdaptiveSection(L)}
<ms-textfield
label="${t("notes_optional", L)}"
multiline
.rows=${3}
.helper=${t("notes_markdown_hint", L)}
.value=${this._notes}
@input=${(e: Event) => (this._notes = (e.target as HTMLInputElement).value)}
></ms-textfield>
@@ -2751,6 +2822,15 @@ export class MaintenanceTaskDialog extends LitElement {
</div>
`
}
<label class="req-option">
<input
type="checkbox"
.checked=${this._requireTagScan}
@change=${(e: Event) => (this._requireTagScan = (e.target as HTMLInputElement).checked)}
/>
<span>${t("require_tag_scan", L)}</span>
</label>
${this._requireTagScan ? html`<div class="field-help">${t("require_tag_scan_help", L)}</div>` : nothing}
<label class="toggle-row">
<input
type="checkbox"
@@ -2923,6 +3003,15 @@ export class MaintenanceTaskDialog extends LitElement {
min-height: 56px;
margin-top: 6px;
}
.phase-req-toggle {
margin-top: 6px;
font-size: 13px;
color: var(--secondary-text-color);
}
.phase-req-fields {
margin: 2px 0 0 22px;
font-size: 13px;
}
.phase-remove {
--mdc-icon-button-size: 36px;
color: var(--secondary-text-color);