429 files
This commit is contained in:
@@ -43,6 +43,15 @@ export class MaintenanceCompleteDialog extends LitElement {
|
||||
/** #139: "2/4 · Flip blades" — which phase of the cycle this completion
|
||||
* records. Purely informative; the backend advances the cursor itself. */
|
||||
@property() public phaseLabel = "";
|
||||
/** Proof of presence: the task completes only via NFC/QR scan — the
|
||||
* dialog says so up front instead of relaying the server's refusal. */
|
||||
@property({ type: Boolean }) public requireTagScan = false;
|
||||
/** This open is the fallback of a QR/NFC scan (quick-complete refused for
|
||||
* missing defaults/details): the completion carries `via_tag_scan` so the
|
||||
* tag-scan gate counts the scan that got the user here, and the "scan
|
||||
* required" note stays hidden — they just did. Set by open(); never
|
||||
* survives into the next open. */
|
||||
@property({ type: Boolean }) public viaTagScan = false;
|
||||
@state() private _open = false;
|
||||
@state() private _notes = "";
|
||||
@state() private _cost = "";
|
||||
@@ -66,9 +75,12 @@ export class MaintenanceCompleteDialog extends LitElement {
|
||||
* prefill the dialog so nobody re-ticks what is already done. */
|
||||
@property({ attribute: false }) public checklistPrefill: Record<string, boolean> = {};
|
||||
|
||||
public open(): void {
|
||||
public open(opts: { viaTagScan?: boolean } = {}): void {
|
||||
if (this._open) return;
|
||||
this._open = true;
|
||||
// Reset on every open — a plain open after a scan fallback must not
|
||||
// keep claiming proof of presence.
|
||||
this.viaTagScan = !!opts.viaTagScan;
|
||||
this._notes = "";
|
||||
this._cost = "";
|
||||
this._duration = "";
|
||||
@@ -173,6 +185,12 @@ export class MaintenanceCompleteDialog extends LitElement {
|
||||
if (this._photoDocId) {
|
||||
data.photo_doc_id = this._photoDocId;
|
||||
}
|
||||
// Scan fallback: the backend accepts via_tag_scan on task/complete so a
|
||||
// require_tag_scan task can still be finished from the dialog the scan
|
||||
// opened. Only ever sent when a scan actually happened.
|
||||
if (this.viaTagScan) {
|
||||
data.via_tag_scan = true;
|
||||
}
|
||||
if (this._completedAt) {
|
||||
// Client-side guard mirrors the backend rule — a picked future moment
|
||||
// fails fast with a localized message instead of a WS roundtrip.
|
||||
@@ -290,6 +308,7 @@ export class MaintenanceCompleteDialog extends LitElement {
|
||||
<ha-dialog open @closed=${this._close}>
|
||||
<div class="dialog-title">${t("complete_title", L)}${this.taskName}</div>
|
||||
${this.phaseLabel ? html`<div class="phase-line">${t("phase_current", L)}: ${this.phaseLabel}</div>` : nothing}
|
||||
${this.requireTagScan && !this.viaTagScan ? html`<div class="scan-required-note">${t("require_tag_scan_hint", L)}</div>` : nothing}
|
||||
<div class="content">
|
||||
${this._error ? html`<div class="error">${this._error}</div>` : nothing}
|
||||
${this.checklist.length > 0 ? html`
|
||||
@@ -477,6 +496,14 @@ export class MaintenanceCompleteDialog extends LitElement {
|
||||
font-weight: 500;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
.scan-required-note {
|
||||
margin: -4px 0 12px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 152, 0, 0.12);
|
||||
color: var(--primary-text-color);
|
||||
font-size: 13px;
|
||||
}
|
||||
.phase-line {
|
||||
margin-top: -8px;
|
||||
padding-bottom: 12px;
|
||||
|
||||
@@ -39,6 +39,10 @@ export class MsTextfield extends LitElement {
|
||||
@property() public max?: string;
|
||||
@property() public pattern?: string;
|
||||
@property() public helper?: string;
|
||||
/** Render a <textarea> instead of an <input> (free-text notes). The
|
||||
* `input` event contract is identical — target.value works for both. */
|
||||
@property({ type: Boolean }) public multiline = false;
|
||||
@property({ type: Number }) public rows = 3;
|
||||
|
||||
/** Forwards the native input's value into our `value` property and
|
||||
* re-fires as a bubbling `input` event so consumers reading
|
||||
@@ -56,6 +60,16 @@ export class MsTextfield extends LitElement {
|
||||
return html`
|
||||
<label class="field">
|
||||
${this.label ? html`<span class="label">${this.label}${this.required ? html`<span class="req">*</span>` : nothing}</span>` : nothing}
|
||||
${this.multiline ? html`
|
||||
<textarea
|
||||
.value=${this.value ?? ""}
|
||||
rows=${this.rows}
|
||||
?required=${this.required}
|
||||
?disabled=${this.disabled}
|
||||
placeholder=${this.placeholder}
|
||||
@input=${this._onInput}
|
||||
@change=${this._onInput}
|
||||
></textarea>` : html`
|
||||
<input
|
||||
.value=${this.value ?? ""}
|
||||
.type=${this.type}
|
||||
@@ -68,7 +82,7 @@ export class MsTextfield extends LitElement {
|
||||
pattern=${this.pattern ?? nothing}
|
||||
@input=${this._onInput}
|
||||
@change=${this._onInput}
|
||||
/>
|
||||
/>`}
|
||||
${this.helper ? html`<span class="helper">${this.helper}</span>` : nothing}
|
||||
</label>
|
||||
`;
|
||||
@@ -103,6 +117,21 @@ export class MsTextfield extends LitElement {
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
input:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
textarea {
|
||||
padding: 8px 10px;
|
||||
font-size: 14px;
|
||||
background: var(--secondary-background-color, rgba(0,0,0,0.06));
|
||||
color: var(--primary-text-color);
|
||||
border: 1px solid var(--divider-color, rgba(255,255,255,0.12));
|
||||
border-radius: 6px;
|
||||
font-family: inherit;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
resize: vertical;
|
||||
}
|
||||
textarea:focus { border-color: var(--primary-color); }
|
||||
textarea:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.helper {
|
||||
font-size: 11px;
|
||||
color: var(--secondary-text-color);
|
||||
|
||||
@@ -217,6 +217,7 @@ export class MaintenanceObjectDialog extends LitElement {
|
||||
.value=${this._notes}
|
||||
@input=${(e: Event) => (this._notes = (e.target as HTMLTextAreaElement).value)}
|
||||
></textarea>
|
||||
<span class="md-hint">${t("notes_markdown_hint", L)}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="dialog-actions">
|
||||
@@ -271,6 +272,9 @@ export class MaintenanceObjectDialog extends LitElement {
|
||||
.textarea-field textarea:focus {
|
||||
outline: none; border-color: var(--primary-color);
|
||||
}
|
||||
.md-hint {
|
||||
font-size: 11px; color: var(--secondary-text-color); font-style: italic;
|
||||
}
|
||||
.parent-select {
|
||||
padding: 8px 10px; font-size: 14px; font-family: inherit;
|
||||
background: var(--secondary-background-color, rgba(0,0,0,0.06));
|
||||
|
||||
+3
-1
@@ -10,6 +10,7 @@
|
||||
|
||||
import { LitElement, html, css, nothing } from "lit";
|
||||
import { isSafeHttpUrl } from "../helpers/url";
|
||||
import { renderNotesMarkdown } from "../helpers/notes-markdown";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { t, STATUS_COLORS, langOf } from "../styles";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
@@ -186,7 +187,7 @@ export class MaintenanceObjectQuickActionsDialog extends LitElement {
|
||||
? html`
|
||||
<div class="notes-section">
|
||||
<strong>${t("object_notes_label", L)}</strong>
|
||||
<div class="notes-body">${obj.notes}</div>
|
||||
<div class="notes-body">${renderNotesMarkdown(obj.notes)}</div>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
@@ -294,6 +295,7 @@ export class MaintenanceObjectQuickActionsDialog extends LitElement {
|
||||
.task-name { flex: 1; font-size: 14px; }
|
||||
.task-status { font-size: 11px; color: var(--secondary-text-color); text-transform: uppercase; }
|
||||
.notes-body { white-space: pre-wrap; font-size: 13px; padding: 8px; background: var(--secondary-background-color); border-radius: 6px; }
|
||||
.notes-body ha-markdown { white-space: normal; }
|
||||
.actions { display: flex; gap: 8px; padding-top: 8px; border-top: 1px solid var(--divider-color); }
|
||||
.actions .btn { flex: 1; }
|
||||
.btn {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { LitElement, html, css, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
||||
import { live } from "lit/directives/live.js";
|
||||
import type { HomeAssistant, AdvancedFeatures, BudgetStatus, HAUser } from "../types";
|
||||
import { t, langOf } from "../styles";
|
||||
import { signApiPath } from "../helpers/document-url";
|
||||
@@ -36,6 +37,8 @@ interface SettingsResponse {
|
||||
// so the picker matches the options-flow dropdown exactly. Optional for
|
||||
// backward-compat with an older backend that didn't send it.
|
||||
notify_targets?: string[];
|
||||
/** v2.67: todo.* entity the buy-task shopping sync mirrors into ("" = off). */
|
||||
shopping_list_entity?: string;
|
||||
panel_enabled: boolean;
|
||||
panel_title: string;
|
||||
/** Opt-in copy of the shipped Assist sentences into the config dir. */
|
||||
@@ -249,6 +252,11 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
this.dispatchEvent(new CustomEvent("settings-changed"));
|
||||
} catch {
|
||||
this._showToast(t("action_error", this._lang));
|
||||
// The control already shows the rejected value (the browser changed it
|
||||
// before we asked the server). `_settings` is unchanged, so a plain
|
||||
// re-render would not touch the DOM — the selects bind their value
|
||||
// through `live()` precisely so this re-render snaps them back.
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -579,15 +587,16 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
<h3>${t("settings_general", L)}</h3>
|
||||
<label class="setting-row">
|
||||
<span class="setting-label">${t("settings_default_warning", L)}</span>
|
||||
<input type="number" min="1" max="365" .value=${String(g.default_warning_days)}
|
||||
<input type="number" min="0" max="365" .value=${String(g.default_warning_days)}
|
||||
@change=${(e: Event) => {
|
||||
const v = parseInt((e.target as HTMLInputElement).value, 10);
|
||||
if (v >= 1 && v <= 365) this._updateSetting("default_warning_days", v);
|
||||
// 0 = no warning window (due soon only on the due date) — #145.
|
||||
if (v >= 0 && v <= 365) this._updateSetting("default_warning_days", v);
|
||||
}} />
|
||||
</label>
|
||||
<label class="setting-row">
|
||||
<span class="setting-label">${t("settings_currency", L)}</span>
|
||||
<select @change=${(e: Event) => this._updateSetting("budget_currency", (e.target as HTMLSelectElement).value)}>
|
||||
<select .value=${live(b.currency)} @change=${(e: Event) => this._updateSetting("budget_currency", (e.target as HTMLSelectElement).value)}>
|
||||
${CURRENCIES.map((c) => html`<option value=${c} ?selected=${b.currency === c}>${c}</option>`)}
|
||||
</select>
|
||||
</label>
|
||||
@@ -652,10 +661,29 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
</div>
|
||||
` : nothing}
|
||||
` : nothing}
|
||||
|
||||
<label class="setting-row">
|
||||
<span class="setting-label" title=${t("settings_shopping_list_help", L)}>${t("settings_shopping_list", L)}</span>
|
||||
<select .value=${live(g.shopping_list_entity || "")}
|
||||
@change=${(e: Event) => this._updateSetting("shopping_list_entity", (e.target as HTMLSelectElement).value)}>
|
||||
<option value="" ?selected=${!g.shopping_list_entity}>${t("shopping_list_none", L)}</option>
|
||||
${this._todoEntities(g.shopping_list_entity || "").map((id) => html`
|
||||
<option value=${id} ?selected=${g.shopping_list_entity === id}>${id}</option>
|
||||
`)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/** All todo.* entities, plus the saved value even when its integration
|
||||
* hasn't loaded (so an offline pick isn't silently dropped from the UI). */
|
||||
private _todoEntities(current: string): string[] {
|
||||
const ids = Object.keys(this.hass?.states || {}).filter((id) => id.startsWith("todo.")).sort();
|
||||
if (current && !ids.includes(current)) ids.unshift(current);
|
||||
return ids;
|
||||
}
|
||||
|
||||
// --- Section: Notifications ---
|
||||
|
||||
private _renderNotifications(L: string) {
|
||||
@@ -762,7 +790,7 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
<label class="setting-row">
|
||||
<span class="setting-label">${t("settings_notify_scope", L)}</span>
|
||||
<select
|
||||
.value=${n.scope_view_id || ""}
|
||||
.value=${live(n.scope_view_id || "")}
|
||||
@change=${(e: Event) => this._updateSetting("notify_scope_view_id", (e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
<option value="" ?selected=${!n.scope_view_id}>${t("settings_notify_scope_all", L)}</option>
|
||||
|
||||
@@ -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);
|
||||
|
||||
+34
-30
@@ -17,8 +17,8 @@ import { property, state } from "lit/decorators.js";
|
||||
import { sharedStyles, t, STATUS_COLORS, formatDate, formatDateTime, formatInterval, formatRecurrence, langOf } from "../styles";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { isoDateLocal } from "../helpers/calendar-bucket";
|
||||
import { partsForCompletion } from "../helpers/shared-parts";
|
||||
import { effectivePhase, phaseLabel } from "../helpers/phases";
|
||||
import { buildCompleteDialogArgs } from "../helpers/complete-dialog-args";
|
||||
import { phaseLabel } from "../helpers/phases";
|
||||
import { renderWeibullSection } from "../renderers/weibull";
|
||||
import { renderPredictionSection } from "../renderers/prediction";
|
||||
import { renderRecommendationBars } from "../renderers/recommendation";
|
||||
@@ -63,6 +63,9 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
|
||||
};
|
||||
@state() private _toast = "";
|
||||
private _featuresLoaded = false;
|
||||
/** Currency symbol for the complete dialog's cost suggestion — read off
|
||||
* the same settings response as the feature flags. */
|
||||
private _currencySymbol = "";
|
||||
|
||||
private get _lang(): string {
|
||||
return langOf(this.hass);
|
||||
@@ -93,10 +96,12 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
|
||||
try {
|
||||
const r = await this.hass.connection.sendMessagePromise<{
|
||||
features?: Partial<AdvancedFeatures>;
|
||||
budget?: { currency_symbol?: string };
|
||||
}>({ type: "maintenance_supporter/settings" });
|
||||
if (r?.features) {
|
||||
this._features = { ...this._features, ...r.features };
|
||||
}
|
||||
this._currencySymbol = r?.budget?.currency_symbol || "";
|
||||
this._featuresLoaded = true;
|
||||
} catch {
|
||||
// Settings endpoint unavailable — leave defaults (all false), the
|
||||
@@ -158,35 +163,34 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
|
||||
// (bug audit 2026-08-22).
|
||||
import("../dialog-mount").then(async ({ openCompleteDialog }) => {
|
||||
const task = this._task!;
|
||||
const isBuy = !!(task as { part_ref?: string }).part_ref;
|
||||
// #139: a phased task completes the phase currently due, so the phase's
|
||||
// checklist/parts/required fields replace the task-level ones here.
|
||||
const phase = effectivePhase(task);
|
||||
const links = phase ? phase.consumesParts : (task.consumes_parts || []);
|
||||
let parts: Parameters<typeof openCompleteDialog>[0]["parts"] = [];
|
||||
if (!isBuy) {
|
||||
try {
|
||||
const r = await this.hass.connection.sendMessagePromise<{
|
||||
objects: MaintenanceObjectResponse[];
|
||||
}>({ type: "maintenance_supporter/objects", compact: true });
|
||||
parts = partsForCompletion({ consumes_parts: links }, this._entryId!, r.objects || [], this._lang);
|
||||
} catch {
|
||||
// Parts stay empty — the dialog still completes without them.
|
||||
}
|
||||
// The object list resolves the parts picker (own inventory + shared
|
||||
// pools, #99/#111), the "consumes" hint lines and a buy task's restock
|
||||
// default — fetched for every task kind, since a buy task needs its
|
||||
// part's restock quantity + unit cost too.
|
||||
let objects: MaintenanceObjectResponse[] = [];
|
||||
try {
|
||||
const r = await this.hass.connection.sendMessagePromise<{
|
||||
objects: MaintenanceObjectResponse[];
|
||||
}>({ type: "maintenance_supporter/objects", compact: true });
|
||||
objects = r.objects || [];
|
||||
} catch {
|
||||
// Parts stay empty — the dialog still completes without them.
|
||||
}
|
||||
const ok = openCompleteDialog({
|
||||
entry_id: this._entryId!,
|
||||
task_id: this._taskId!,
|
||||
task_name: task.name,
|
||||
checklist: phase ? phase.checklist : (task.checklist || []),
|
||||
adaptive_enabled: !!task.adaptive_config?.enabled,
|
||||
required_completion_fields: phase ? phase.requiredFields : (task.required_completion_fields || []),
|
||||
task_type: task.type || "",
|
||||
reading_unit: (task as { reading_unit?: string }).reading_unit || "",
|
||||
parts,
|
||||
consumes_parts: isBuy ? [] : links,
|
||||
phase_label: phaseLabel(task),
|
||||
});
|
||||
// Same derivation as the panel and the card (phase override, tag-scan
|
||||
// gate, checklist ticks …) — see helpers/complete-dialog-args.
|
||||
const ok = openCompleteDialog(
|
||||
buildCompleteDialogArgs({
|
||||
entryId: this._entryId!,
|
||||
taskId: this._taskId!,
|
||||
taskName: task.name,
|
||||
task,
|
||||
objects,
|
||||
lang: this._lang,
|
||||
checklist: task.checklist || [],
|
||||
adaptiveEnabled: !!task.adaptive_config?.enabled,
|
||||
currencySymbol: this._currencySymbol,
|
||||
}),
|
||||
);
|
||||
if (ok) {
|
||||
this._notifyChanged("complete");
|
||||
this.close();
|
||||
|
||||
Reference in New Issue
Block a user