/** v2.3.0 — Task Quick-Actions Dialog. * * Opens from the Lovelace card / strategy when the user clicks a task row. * Surfaces every action the panel's Task-Detail header offers, in-place, * without forcing a panel-roundtrip: * * • Quick info — name + status + next due + last performed + interval * • Primary actions — Complete (existing dialog), Skip (inline), Reset (inline) * • Secondary (admin) — Edit settings (existing dialog), QR Code, Delete * • Footer — "Open in Maintenance Panel" deep-link for History/Statistics * * Mounted via dialog-mount.openTaskQuickActions(entryId, taskId). */ import { LitElement, html, css, nothing } from "lit"; import { property, state } from "lit/decorators.js"; import { sharedStyles, t, STATUS_COLORS, formatDate, formatDateTime, formatRecurrence } from "../styles"; import { describeWsError } from "../ws-errors"; import { renderWeibullSection } from "../renderers/weibull"; import { renderPredictionSection } from "../renderers/prediction"; import { renderRecommendationBars } from "../renderers/recommendation"; import { renderSeasonalCardCompact, renderSeasonalCardExpanded, } from "../renderers/seasonal"; import type { HomeAssistant, HistoryEntry, MaintenanceObjectResponse, MaintenanceTask, AdvancedFeatures, } from "../types"; interface MaintenanceObjectFull { entry_id: string; object: { id: string; name: string }; tasks: MaintenanceTask[]; } export class MaintenanceTaskQuickActionsDialog extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @state() private _open = false; @state() private _entryId: string | null = null; @state() private _taskId: string | null = null; @state() private _task: MaintenanceTask | null = null; @state() private _objectName = ""; @state() private _busy = false; @state() private _error = ""; @state() private _showSkip = false; @state() private _showReset = false; @state() private _showDetails = false; @state() private _showAdaptive = false; @state() private _skipReason = ""; @state() private _resetDate = ""; @state() private _features: AdvancedFeatures = { adaptive: false, predictions: false, seasonal: false, environmental: false, budget: false, groups: false, checklists: false, schedule_time: false, completion_actions: false, }; @state() private _toast = ""; private _featuresLoaded = false; private get _lang(): string { return this.hass?.language || "en"; } /** Open the dialog. Loads fresh data from /object via WS so dialog stays in * sync even if the underlying card has stale data. */ public async openFor(entryId: string, taskId: string): Promise { this._entryId = entryId; this._taskId = taskId; this._error = ""; this._showSkip = false; this._showReset = false; this._showAdaptive = false; this._skipReason = ""; this._resetDate = new Date().toISOString().slice(0, 10); this._open = true; await Promise.all([this._loadTask(), this._loadFeatures()]); } /** Pull the active feature flags so adaptive sections only render when * Adaptive / Seasonal / Environmental are actually enabled (matches the * panel's behaviour). Cached after first load. */ private async _loadFeatures(): Promise { if (this._featuresLoaded) return; try { const r = await this.hass.connection.sendMessagePromise<{ features?: Partial; }>({ type: "maintenance_supporter/settings" }); if (r?.features) { this._features = { ...this._features, ...r.features }; } this._featuresLoaded = true; } catch { // Settings endpoint unavailable — leave defaults (all false), the // adaptive panel will then stay hidden. } } public close(): void { this._open = false; this._task = null; this._error = ""; } private async _loadTask(): Promise { if (!this._entryId || !this._taskId) return; try { const r = await this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/object", entry_id: this._entryId, }); this._objectName = r.object?.name || ""; const found = (r.tasks || []).find((t) => t.id === this._taskId); this._task = found ?? null; } catch (e) { this._error = describeWsError(e, this._lang); } } private async _runWs(payload: Record): Promise { this._busy = true; this._error = ""; try { await this.hass.connection.sendMessagePromise(payload); this._busy = false; return true; } catch (e) { this._error = describeWsError(e, this._lang); this._busy = false; return false; } } private _notifyChanged(action: string): void { this.dispatchEvent( new CustomEvent("task-action-fired", { detail: { entry_id: this._entryId, task_id: this._taskId, action }, bubbles: true, composed: true, }), ); } private _onComplete(): void { if (!this._entryId || !this._taskId || !this._task) return; // Reuse the existing rich complete-dialog by mounting it on body import("../dialog-mount").then(({ openCompleteDialog }) => { const ok = openCompleteDialog({ entry_id: this._entryId!, task_id: this._taskId!, task_name: this._task!.name, checklist: this._task!.checklist || [], adaptive_enabled: !!this._task!.adaptive_config?.enabled, }); if (ok) { this._notifyChanged("complete"); this.close(); } }); } private async _onSkipConfirm(): Promise { if (!this._entryId || !this._taskId) return; const ok = await this._runWs({ type: "maintenance_supporter/task/skip", entry_id: this._entryId, task_id: this._taskId, reason: this._skipReason.trim() || null, }); if (ok) { this._notifyChanged("skip"); this.close(); } } private async _onResetConfirm(): Promise { if (!this._entryId || !this._taskId) return; const ok = await this._runWs({ type: "maintenance_supporter/task/reset", entry_id: this._entryId, task_id: this._taskId, date: this._resetDate || undefined, }); if (ok) { this._notifyChanged("reset"); this.close(); } } private _onEdit(): void { if (!this._entryId || !this._taskId) return; import("../dialog-mount").then(({ openEditTaskDialog }) => { openEditTaskDialog(this._entryId!, this._taskId!); this.close(); }); } private _onQr(): void { if (!this._entryId || !this._taskId || !this._task) return; import("../dialog-mount").then(({ openQrDialog }) => { openQrDialog({ entry_id: this._entryId!, task_id: this._taskId!, task_name: this._task!.name, object_name: this._objectName, }); this.close(); }); } private async _onDelete(): Promise { if (!this._entryId || !this._taskId) return; const confirmText = t("delete_task_confirm", this._lang) || `Delete "${this._task?.name}"?`; if (!window.confirm(confirmText)) return; const ok = await this._runWs({ type: "maintenance_supporter/task/delete", entry_id: this._entryId, task_id: this._taskId, }); if (ok) { this._notifyChanged("delete"); this.close(); } } private async _onArchive(): Promise { if (!this._entryId || !this._taskId) return; const ok = await this._runWs({ type: "maintenance_supporter/task/archive", entry_id: this._entryId, task_id: this._taskId, }); if (ok) { this._notifyChanged("archive"); this.close(); } } private async _onUnarchive(): Promise { if (!this._entryId || !this._taskId) return; const ok = await this._runWs({ type: "maintenance_supporter/task/unarchive", entry_id: this._entryId, task_id: this._taskId, }); if (ok) { this._notifyChanged("unarchive"); this.close(); } } private _onOpenInPanel(): void { if (!this._entryId || !this._taskId) return; const path = `/maintenance-supporter?entry_id=${encodeURIComponent(this._entryId)}` + `&task_id=${encodeURIComponent(this._taskId)}`; history.pushState(null, "", path); window.dispatchEvent(new CustomEvent("location-changed")); this.close(); } private async _applySuggestion(): Promise { if (!this._entryId || !this._taskId || !this._task?.suggested_interval) return; const ok = await this._runWs({ type: "maintenance_supporter/task/apply_suggestion", entry_id: this._entryId, task_id: this._taskId, interval: this._task.suggested_interval, }); if (ok) { this._toast = t("suggestion_applied", this._lang) || "Applied"; this._notifyChanged("apply_suggestion"); // Refresh local task so the recommendation card hides await this._loadTask(); setTimeout(() => { this._toast = ""; }, 2500); } } private async _reanalyzeInterval(): Promise { if (!this._entryId || !this._taskId) return; this._busy = true; this._error = ""; try { const r = await this.hass.connection.sendMessagePromise<{ recommended_interval: number | null; confidence: string; data_points: number; }>({ type: "maintenance_supporter/task/analyze_interval", entry_id: this._entryId, task_id: this._taskId, }); this._toast = r.recommended_interval ? `${t("reanalyze_result", this._lang) || "Recomputed"}: ${r.recommended_interval}d (${r.data_points} pts)` : (t("reanalyze_insufficient_data", this._lang) || "Not enough data"); await this._loadTask(); setTimeout(() => { this._toast = ""; }, 3500); } catch (e) { this._error = describeWsError(e, this._lang); } finally { this._busy = false; } } private _onEditHistoryEntry(entry: HistoryEntry): void { if (!this._entryId || !this._taskId) return; import("../dialog-mount").then(({ openHistoryEditDialog }) => { openHistoryEditDialog({ entry_id: this._entryId!, task_id: this._taskId!, original_timestamp: entry.timestamp, type: entry.type, timestamp: entry.timestamp, notes: entry.notes ?? null, cost: entry.cost ?? null, duration: entry.duration ?? null, completed_by: entry.completed_by ?? null, }); }); } /** Inline recommendation card (Current vs Suggested with apply/reanalyze). * Bars + confidence badge come from the shared renderer; the action row * uses native `; } /** Adaptive section: prediction + recommendation + Weibull + seasonal, * reusing the panel's renderers. Only renders blocks that have data. */ private _renderAdaptive(task: MaintenanceTask) { const L = this._lang; const hasRecommendation = this._features.adaptive && task.suggested_interval && task.suggested_interval !== task.interval_days; const hasPrediction = (task.degradation_trend != null && task.degradation_trend !== "insufficient_data") || task.days_until_threshold != null || (task.environmental_factor != null && task.environmental_factor !== 1.0); const hasWeibull = this._features.adaptive && task.interval_analysis?.weibull_beta != null && task.interval_analysis?.weibull_eta != null; const hasSeasonal = this._features.seasonal && task.seasonal_factor && task.seasonal_factor !== 1.0; if (!hasRecommendation && !hasPrediction && !hasWeibull && !hasSeasonal) { return html`
${t("adaptive_no_data", L) || "Not enough completion history yet for adaptive analysis."}
`; } return html`
${this._toast ? html`
${this._toast}
` : nothing} ${hasRecommendation ? this._renderRecommendation(task) : nothing} ${hasPrediction ? renderPredictionSection(task, L, this._features) : nothing} ${hasWeibull ? renderWeibullSection(task, L) : nothing} ${hasSeasonal ? html` ${renderSeasonalCardCompact(task, L, this._features)} ${task.seasonal_factors?.length === 12 || task.interval_analysis?.seasonal_factors?.length === 12 ? renderSeasonalCardExpanded(task, L) : nothing} ` : nothing}
`; } /** Read-only details panel: stats + history. Shown when the user clicks * "Show details" in the dialog. Edit-buttons on history entries open the * existing history-edit dialog (which lives in the same dialog-mount). */ private _renderDetails(task: MaintenanceTask) { const L = this._lang; const history = (task.history || []) as HistoryEntry[]; const completed = history.filter((h) => h.type === "completed"); const totalCost = completed.reduce( (s, h) => s + (typeof h.cost === "number" ? h.cost : 0), 0, ); const avgDuration = (() => { const durs = completed .map((h) => (typeof h.duration === "number" ? h.duration : null)) .filter((d): d is number => d != null); if (!durs.length) return null; return Math.round(durs.reduce((s, d) => s + d, 0) / durs.length); })(); return html`
${t("times_performed", L) || "Performed"} ${completed.length}
${t("total_cost", L) || "Total cost"} ${totalCost.toFixed(2)}
${t("avg_duration", L) || "Avg duration"} ${avgDuration != null ? `${avgDuration}m` : "—"}
${t("history", L) || "History"} ${history.length}
${history.length === 0 ? html`
${t("history_empty", L) || "No history yet."}
` : html`
${[...history].reverse().slice(0, 20).map((entry) => { const editable = ["completed", "reset", "skipped"].includes(entry.type); return html`
${t(entry.type, L)} ${formatDateTime(entry.timestamp, L)} ${editable ? html`` : nothing}
${entry.notes ? html`
${entry.notes}
` : nothing} ${entry.cost != null || entry.duration != null ? html`
${entry.cost != null ? html`💰 ${entry.cost.toFixed(2)}` : nothing} ${entry.duration != null ? html`⏱️ ${entry.duration}m` : nothing}
` : nothing}
`; })} ${history.length > 20 ? html`
… +${history.length - 20} ${t("older_entries", L) || "older"}
` : nothing}
`}
`; } render() { if (!this._open) return nothing; const L = this._lang; const task = this._task; const isAdmin = (this.hass?.user?.is_admin ?? true) as boolean; return html`
`; } static styles = [sharedStyles, css` :host { display: contents; } .backdrop { position: fixed; inset: 0; z-index: 100; background: rgba(0,0,0,0.5); } .dialog { position: fixed; left: 50%; top: 50%; transform: translate(-50%, -50%); width: 95vw; max-width: 460px; max-height: 92vh; overflow: auto; background: var(--card-background-color, var(--ha-card-background, #1c1c1c)); color: var(--primary-text-color); border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,0.4); padding: 20px; display: flex; flex-direction: column; gap: 14px; z-index: 101; } .header { display: flex; flex-direction: column; gap: 6px; } .title { display: flex; align-items: center; gap: 10px; } .status-dot { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; } .task-name { font-size: 18px; font-weight: 600; } .object { font-size: 13px; color: var(--secondary-text-color); } .link-inline { background: transparent; border: none; padding: 0; cursor: pointer; color: var(--primary-color); font-size: inherit; font-family: inherit; } .link-inline:hover { text-decoration: underline; } .quick-info { display: flex; flex-wrap: wrap; gap: 12px; font-size: 12px; color: var(--secondary-text-color); padding-top: 4px; border-top: 1px solid var(--divider-color); } .quick-info strong { color: var(--primary-text-color); font-weight: 500; } .actions { display: flex; gap: 8px; } .actions.primary-row { gap: 6px; } .actions.primary-row .btn { flex: 1; } /* Edit + QR are admin-tools — left-align as a group; Delete is destructive so it gets pushed to the far right with margin-left:auto for visual separation. Earlier this row was flex-end which left a strange empty gap on the left (user feedback). */ .actions.secondary-row { padding-top: 8px; border-top: 1px solid var(--divider-color); justify-content: flex-start; } .actions.secondary-row .btn.danger { margin-left: auto; } .btn { padding: 8px 12px; font-size: 14px; border-radius: 6px; cursor: pointer; border: 1px solid var(--divider-color); background: var(--secondary-background-color, transparent); color: var(--primary-text-color); font-weight: 500; display: inline-flex; align-items: center; gap: 6px; transition: background 0.12s; } .btn:hover { background: var(--state-icon-color, rgba(255,255,255,0.06)); } .btn[disabled] { opacity: 0.5; cursor: wait; } .btn.primary { background: var(--primary-color); color: var(--text-primary-color, white); border-color: var(--primary-color); } .btn.cancel { background: transparent; } .btn.ghost { padding: 6px 10px; font-size: 13px; } .btn.danger { color: var(--error-color); } .btn ha-icon { --mdc-icon-size: 18px; } .inline-form { display: flex; flex-direction: column; gap: 8px; } .inline-form label { font-size: 13px; color: var(--secondary-text-color); } .inline-form input { padding: 8px; font-size: 14px; background: var(--secondary-background-color, #2c2c2c); color: var(--primary-text-color); border: 1px solid var(--divider-color, #444); border-radius: 6px; } .inline-actions { display: flex; gap: 8px; justify-content: flex-end; } .footer { display: flex; justify-content: center; padding-top: 4px; } .link { background: transparent; border: none; cursor: pointer; color: var(--primary-color); font-size: 13px; display: inline-flex; align-items: center; gap: 4px; } .link:hover { text-decoration: underline; } .link ha-icon { --mdc-icon-size: 14px; } .loading { padding: 24px; text-align: center; color: var(--secondary-text-color); } .error { padding: 8px; border-radius: 6px; background: rgba(211,47,47,0.1); color: var(--error-color, #d32f2f); font-size: 13px; } /* Details (expandable Show details section) */ .details-toggle { display: flex; justify-content: center; margin-top: 4px; } .details { display: flex; flex-direction: column; gap: 12px; border-top: 1px solid var(--divider-color); padding-top: 12px; } .stats-grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; } .stat { display: flex; flex-direction: column; gap: 2px; background: var(--secondary-background-color, rgba(255,255,255,0.04)); padding: 8px; border-radius: 6px; align-items: center; } .stat-label { font-size: 11px; color: var(--secondary-text-color); text-transform: uppercase; letter-spacing: 0.5px; } .stat-value { font-size: 16px; font-weight: 600; } .history-header { display: flex; align-items: baseline; gap: 8px; font-size: 14px; } .history-count { font-size: 12px; color: var(--secondary-text-color); background: var(--secondary-background-color); padding: 2px 8px; border-radius: 999px; } .history-empty { color: var(--secondary-text-color); font-style: italic; font-size: 13px; } .history-list { display: flex; flex-direction: column; gap: 8px; max-height: 280px; overflow: auto; } .history-entry { padding: 6px 8px; border-radius: 6px; background: var(--secondary-background-color, rgba(255,255,255,0.03)); font-size: 13px; } .history-line { display: flex; align-items: center; gap: 8px; justify-content: space-between; } .history-type { font-weight: 600; font-size: 11px; padding: 2px 6px; border-radius: 4px; text-transform: uppercase; letter-spacing: 0.5px; } .type-completed { background: rgba(46,125,50,0.2); color: #66bb6a; } .type-skipped { background: rgba(158,158,158,0.2); color: var(--secondary-text-color); } .type-reset { background: rgba(33,150,243,0.2); color: #64b5f6; } .type-triggered { background: rgba(255,87,34,0.2); color: #ff8a65; } .history-date { font-size: 11px; color: var(--secondary-text-color); flex: 1; text-align: right; } .history-edit { background: transparent; border: none; cursor: pointer; padding: 4px; border-radius: 4px; color: var(--secondary-text-color); } .history-edit:hover { background: var(--state-icon-color, rgba(255,255,255,0.06)); color: var(--primary-color); } .history-edit ha-icon { --mdc-icon-size: 14px; } .history-notes { margin-top: 4px; color: var(--primary-text-color); } .history-meta { display: flex; gap: 12px; margin-top: 4px; color: var(--secondary-text-color); font-size: 11px; } .history-more { padding: 8px; text-align: center; font-size: 12px; color: var(--secondary-text-color); font-style: italic; } /* Adaptive section — wraps the panel renderers (which assume sharedStyles are present) and adds dialog-specific layout. */ .adaptive-stack { display: flex; flex-direction: column; gap: 12px; border-top: 1px solid var(--divider-color); padding-top: 12px; } .adaptive-empty { padding: 16px; text-align: center; color: var(--secondary-text-color); font-style: italic; font-size: 13px; border-top: 1px solid var(--divider-color); } .toast { padding: 8px 12px; border-radius: 6px; background: rgba(76, 175, 80, 0.15); color: #4caf50; font-size: 13px; font-weight: 500; } /* The panel's recommendation-card uses ha-button. We use plain