429 files

This commit is contained in:
Home Assistant Version Control
2026-08-30 02:43:31 +00:00
parent 472c19bceb
commit 963cce81a7
566 changed files with 50311 additions and 47616 deletions
@@ -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));
@@ -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);
@@ -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();