Files
Home-Assistant/custom_components/maintenance_supporter/frontend-src/renderers/task-detail.ts
T
2026-07-20 22:52:35 -04:00

453 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** Task-detail sub-view: header, tab bar, overview tab (KPIs, meta, charts,
* analysis cards), history tab, and the documents section.
*
* Extracted from the panel (renderers/ pattern, like history.ts). State the
* panel owns — the active tab, collapsed sections, feature flags — plus the
* ~20 action callbacks (complete/skip/archive/QR/edit/…) are passed in via
* TaskDetailContext. Dialog ownership deliberately STAYS in the panel: every
* dialog-opening callback runs panel-side against the panel's shadow root, so
* this module renders into the panel's root and never needs its own dialogs.
*/
import { html, nothing } from "lit";
import { isSafeHttpUrl } from "../helpers/url";
import { t, formatDate, formatDateTime, formatRecurrence } from "../styles";
import type { AdvancedFeatures, HomeAssistant, MaintenanceTask } from "../types";
import { renderTriggerSection, type SparklineContext } from "./sparkline";
import { renderPredictionSection } from "./prediction";
import { renderWeibullSection } from "./weibull";
import { renderRecommendationBars } from "./recommendation";
import { renderSeasonalCardCompact, renderSeasonalCardExpanded } from "./seasonal";
import { renderCostDurationCard } from "./charts";
import { renderDaysProgress } from "./progress";
import { renderHistoryFilters, renderHistoryList, type HistoryContext } from "./history";
import "../components/task-documents";
export interface TaskDetailContext {
lang: string;
hass: HomeAssistant;
entryId: string;
taskId: string;
/** Parent object's display name (breadcrumb + object-manual label). */
objectName: string;
/** Parent object's documentation_url (raw; sanitised here). */
objectDocUrl: string | null | undefined;
isOperator: boolean;
actionLoading: boolean;
moreMenuOpen: boolean;
activeTab: "overview" | "history";
features: AdvancedFeatures;
currencySymbol: string;
collapsedSections: Set<string>;
costDurationToggle: "cost" | "duration" | "both";
/** Whether the interval suggestion for THIS task was dismissed this session. */
suggestionDismissed: boolean;
/** Sub-contexts for the chart + history renderers. */
sparkline: SparklineContext;
history: HistoryContext;
getUserName: (userId: string) => string | null;
// ── Panel-owned state mutations ────────────────────────────────────────
setActiveTab: (tab: "overview" | "history") => void;
toggleSection: (key: string) => void;
setCostDurationToggle: (v: "cost" | "duration" | "both") => void;
showTaskView: () => void;
showObject: () => void;
toggleMoreMenu: () => void;
closeMoreMenu: () => void;
// ── Panel-owned actions (dialogs live in the panel's shadow root) ──────
openEdit: (task: MaintenanceTask) => void;
openComplete: (task: MaintenanceTask) => void;
promptSkip: () => void;
toggleArchive: (archived: boolean) => void;
openQr: (taskName: string) => void;
duplicateTask: () => void;
promptReset: () => void;
promptPostpone: () => void;
snoozeTask: () => void;
/** v2.21: open the printable one-pager for this task. */
printWorksheet: () => void;
deleteTask: () => void;
applySuggestion: (interval: number) => void;
reanalyze: () => void;
dismissSuggestion: () => void;
openSeasonalOverrides: (task: MaintenanceTask) => void;
}
/** User badge for a task (if a responsible user is assigned). Also used by
* the object-detail task list, so it takes the lookup directly. */
export function renderUserBadge(
task: MaintenanceTask,
getUserName: (userId: string) => string | null,
) {
if (!task.responsible_user_id) return nothing;
const userName = getUserName(task.responsible_user_id);
if (!userName) return nothing;
return html`
<span class="user-badge">
<ha-icon icon="mdi:account"></ha-icon>
${userName}
</span>
`;
}
function renderTaskHeader(task: MaintenanceTask, ctx: TaskDetailContext) {
const L = ctx.lang;
const isOperator = ctx.isOperator;
// Determine status chip — use the backend-computed status. A completed
// one-time task is shown as archived ("done") rather than its raw "ok".
const statusClass = task.archived ? "archived" : (task.is_done ? "done" : (task.status === "due_soon" ? "warning" : (task.status || "ok")));
const statusText = task.archived ? t("archived", L) : (task.is_done ? t("completed", L) : t(task.status || "ok", L));
return html`
<div class="task-header">
<div class="task-header-title">
<span class="task-name-breadcrumb" @click=${() => ctx.showTaskView()}>${task.name}</span>
<span class="breadcrumb-separator">·</span>
<span class="object-name-breadcrumb" @click=${() => ctx.showObject()}>${ctx.objectName}</span>
<span class="status-chip ${statusClass}">${statusText}</span>
${task.due_override ? html`<span class="postponed-badge" title="${t("postponed_to", L)}">
<ha-icon icon="mdi:calendar-arrow-right"></ha-icon>${formatDate(task.due_override, L)}
</span>` : nothing}
${renderUserBadge(task, ctx.getUserName)}
${task.nfc_tag_id
? html`<span class="nfc-badge" title="${t("nfc_tag_id", L)}: ${task.nfc_tag_id}"><ha-icon icon="mdi:nfc-variant"></ha-icon> NFC</span>`
: !isOperator ? html`<span class="nfc-badge unlinked" title="${t("nfc_link_hint", L)}"
@click=${() => ctx.openEdit(task)}>
<ha-icon icon="mdi:nfc-variant"></ha-icon>
</span>` : nothing
}
</div>
<div class="task-header-actions">
<ha-button appearance="filled" @click=${() => ctx.openComplete(task)}>${t("complete", L)}</ha-button>
<ha-button appearance="plain" .disabled=${ctx.actionLoading} @click=${() => ctx.promptSkip()}>${t("skip", L)}</ha-button>
${!isOperator ? html`
<ha-button appearance="plain" @click=${() => ctx.toggleArchive(!!task.archived)}>
<ha-icon icon="${task.archived ? 'mdi:archive-arrow-up-outline' : 'mdi:archive-outline'}"></ha-icon>
${task.archived ? t("unarchive", L) : t("archive", L)}
</ha-button>
` : nothing}
<ha-button appearance="plain" @click=${() => ctx.openQr(task.name)}><ha-icon icon="mdi:qrcode"></ha-icon> ${t("qr_code", L)}</ha-button>
${!isOperator ? html`
<div class="more-menu-wrapper">
<ha-icon-button .disabled=${ctx.actionLoading} .path=${"M12,16A2,2 0 0,1 14,18A2,2 0 0,1 12,20A2,2 0 0,1 10,18A2,2 0 0,1 12,16M12,10A2,2 0 0,1 14,12A2,2 0 0,1 12,14A2,2 0 0,1 10,12A2,2 0 0,1 12,10M12,4A2,2 0 0,1 14,6A2,2 0 0,1 12,8A2,2 0 0,1 10,6A2,2 0 0,1 12,4Z"} @click=${() => ctx.toggleMoreMenu()}></ha-icon-button>
${ctx.moreMenuOpen ? html`
<div class="popup-menu" @click=${(e: Event) => e.stopPropagation()}>
<div class="popup-menu-item" @click=${() => { ctx.closeMoreMenu(); ctx.openEdit(task); }}>${t("edit", L)}</div>
<div class="popup-menu-item" @click=${() => ctx.duplicateTask()}>${t("duplicate", L)}</div>
<div class="popup-menu-item" @click=${() => { ctx.closeMoreMenu(); ctx.promptReset(); }}>${t("reset", L)}</div>
<div class="popup-menu-item" @click=${() => { ctx.closeMoreMenu(); ctx.promptPostpone(); }}>${t("postpone", L)}…</div>
<div class="popup-menu-item" @click=${() => { ctx.closeMoreMenu(); ctx.snoozeTask(); }}>${t("snooze", L)}</div>
<div class="popup-menu-item" @click=${() => { ctx.closeMoreMenu(); ctx.printWorksheet(); }}>${t("worksheet", L)}</div>
<div class="popup-menu-divider"></div>
<div class="popup-menu-item danger" @click=${() => { ctx.closeMoreMenu(); ctx.deleteTask(); }}>${t("delete", L)}</div>
</div>
` : nothing}
</div>
` : nothing}
</div>
</div>
`;
}
function renderTabBar(ctx: TaskDetailContext) {
const L = ctx.lang;
return html`
<div class="tab-bar">
<div class="tab ${ctx.activeTab === "overview" ? "active" : ""}" @click=${() => ctx.setActiveTab("overview")}>
${t("overview", L)}
</div>
<div class="tab ${ctx.activeTab === "history" ? "active" : ""}" @click=${() => ctx.setActiveTab("history")}>
${t("history", L)}
</div>
</div>
`;
}
/** Wrap a task-detail analysis card in a collapsible section. The section
* header owns the title (the wrapped card's own title is hidden via CSS —
* `.collapsible-body` — so it isn't shown twice). Remembered per key. */
function collapsible(key: string, titleKey: string, body: unknown, ctx: TaskDetailContext) {
const collapsed = ctx.collapsedSections.has(key);
return html`
<div class="collapsible ${collapsed ? "collapsed" : ""}">
<button class="collapsible-head" @click=${() => ctx.toggleSection(key)}
aria-expanded=${collapsed ? "false" : "true"}>
<ha-icon icon="${collapsed ? "mdi:chevron-right" : "mdi:chevron-down"}"></ha-icon>
<span>${t(titleKey, ctx.lang)}</span>
</button>
${collapsed ? nothing : html`<div class="collapsible-body">${body}</div>`}
</div>
`;
}
/** Read-only preview of the configured checklist steps so users can see
* the steps without having to open the Edit or Complete dialog. Only
* rendered when the Checklists feature is enabled and steps are set. */
function renderChecklistCard(task: MaintenanceTask, ctx: TaskDetailContext) {
if (!ctx.features.checklists) return nothing;
const items = task.checklist || [];
if (items.length === 0) return nothing;
const L = ctx.lang;
return html`
<div class="checklist-preview-card">
<div class="checklist-preview-header">
<ha-icon icon="mdi:format-list-checks"></ha-icon>
<span>${t("checklist", L)} (${items.length})</span>
</div>
<ol class="checklist-preview-list">
${items.map((item) => html`<li>${item}</li>`)}
</ol>
</div>
`;
}
/** Task notes, task documentation URL, and (since v1.4.1) the parent object's
* documentation_url for quick access to the device manual without having to
* navigate back to the object detail. */
function renderTaskMeta(task: MaintenanceTask, ctx: TaskDetailContext) {
const safeTaskUrl = isSafeHttpUrl(task.documentation_url)
? task.documentation_url : null;
const safeObjUrl = isSafeHttpUrl(ctx.objectDocUrl) ? ctx.objectDocUrl : null;
if (!task.notes && !safeTaskUrl && !safeObjUrl) return nothing;
const L = ctx.lang;
return html`
<div class="task-meta-card">
${task.notes ? html`
<div class="task-meta-row">
<ha-icon icon="mdi:note-text-outline"></ha-icon>
<span class="task-meta-notes">${task.notes}</span>
</div>
` : nothing}
${safeTaskUrl ? html`
<div class="task-meta-row task-meta-link">
<ha-icon icon="mdi:open-in-new"></ha-icon>
<a href="${safeTaskUrl}" target="_blank" rel="noopener noreferrer">${t("documentation_label", L)}</a>
</div>
` : nothing}
${safeObjUrl ? html`
<div class="task-meta-row task-meta-link">
<ha-icon icon="mdi:book-open-variant"></ha-icon>
<a href="${safeObjUrl}" target="_blank" rel="noopener noreferrer">${t("documentation_url_label", L)} (${ctx.objectName})</a>
</div>
` : nothing}
</div>
`;
}
/** KPI bar with 7 cards. */
function renderKPIBar(task: MaintenanceTask, ctx: TaskDetailContext) {
const L = ctx.lang;
const avgCost = task.times_performed > 0 ? task.total_cost / task.times_performed : 0;
const daysClass = task.days_until_due !== null && task.days_until_due !== undefined
? (task.days_until_due < 0 ? "overdue" : (task.days_until_due <= task.warning_days ? "warning" : ""))
: "";
return html`
<div class="kpi-bar">
<div class="kpi-card">
<div class="kpi-label">${t("next_due", L)}</div>
<div class="kpi-value">${task.next_due ? formatDate(task.next_due, L) : "—"}</div>
${ctx.features.schedule_time && task.schedule_time
? html`<div class="kpi-subtext">${t("at_time", L)} ${task.schedule_time}</div>`
: nothing}
</div>
<div class="kpi-card ${daysClass}">
<div class="kpi-label">${t("days_until_due", L)}</div>
<div class="kpi-value-large">${task.days_until_due !== null && task.days_until_due !== undefined ? task.days_until_due : "—"}</div>
</div>
<div class="kpi-card">
<div class="kpi-label">${t("interval", L)}</div>
<div class="kpi-value">${formatRecurrence(task, L)}</div>
${ctx.features.adaptive && task.suggested_interval && task.suggested_interval !== task.interval_days ? html`
<div class="kpi-subtext">${t("recommended", L)}: ${task.suggested_interval}${task.interval_analysis?.confidence_interval_low != null ? ` (${task.interval_analysis.confidence_interval_low}${task.interval_analysis.confidence_interval_high})` : ""}</div>
` : nothing}
</div>
<div class="kpi-card">
<div class="kpi-label">${t("warning", L)}</div>
<div class="kpi-value">${task.warning_days} ${t("days", L)}</div>
</div>
<div class="kpi-card">
<div class="kpi-label">${t("last_performed", L)}</div>
<div class="kpi-value">${task.last_performed ? formatDate(task.last_performed, L) : "—"}</div>
</div>
<div class="kpi-card">
<div class="kpi-label">${t("avg_cost", L)}</div>
<div class="kpi-value">${avgCost.toFixed(0)} ${ctx.currencySymbol}</div>
</div>
<div class="kpi-card">
<div class="kpi-label">${t("avg_duration", L)}</div>
<div class="kpi-value">${task.average_duration ? task.average_duration.toFixed(0) : "—"} min</div>
</div>
</div>
`;
}
function renderRecommendationCard(task: MaintenanceTask, ctx: TaskDetailContext) {
const L = ctx.lang;
if (!ctx.features.adaptive || !task.suggested_interval
|| task.suggested_interval === task.interval_days) {
return nothing;
}
if (ctx.suggestionDismissed) return nothing;
const suggested = task.suggested_interval;
return html`
<div class="recommendation-card">
<h4>${t("suggested_interval", L)}</h4>
${renderRecommendationBars(
task.interval_days, suggested,
task.interval_confidence || "medium", L,
)}
<div class="recommendation-actions">
<ha-button appearance="filled"
@click=${() => ctx.applySuggestion(suggested)}>
${t("apply_suggestion", L)}
</ha-button>
<ha-button appearance="plain"
@click=${() => ctx.reanalyze()}>
${t("reanalyze", L)}
</ha-button>
<ha-button appearance="plain"
@click=${() => ctx.dismissSuggestion()}>
${t("dismiss_suggestion", L)}
</ha-button>
</div>
</div>
`;
}
function renderRecentActivities(task: MaintenanceTask, ctx: TaskDetailContext) {
const L = ctx.lang;
const recent = task.history.slice(-3).reverse();
if (recent.length === 0) {
return nothing;
}
const getIcon = (type: string) => {
switch (type) {
case "completed": return "✓";
case "triggered": return "⊗";
case "skipped": return "↷";
case "reset": return "↺";
default: return "·";
}
};
return html`
<div class="recent-activities">
<h3>${t("recent_activities", L)}</h3>
${recent.map(entry => html`
<div class="activity-item">
<span class="activity-icon">${getIcon(entry.type)}</span>
<span class="activity-date">${formatDateTime(entry.timestamp, L)}</span>
<span class="activity-note">${entry.notes || "—"}</span>
${entry.cost ? html`<span class="activity-badge">${entry.cost.toFixed(0)}${ctx.currencySymbol}</span>` : nothing}
${entry.duration ? html`<span class="activity-badge">${entry.duration}min</span>` : nothing}
</div>
`)}
<div class="activity-show-all">
<ha-button appearance="plain" @click=${() => ctx.setActiveTab("history")}>${t("show_all", L)} →</ha-button>
</div>
</div>
`;
}
export function renderOverviewTab(task: MaintenanceTask, ctx: TaskDetailContext) {
const L = ctx.lang;
// Check if we have recommendation / seasonal content
const hasRecommendation = ctx.features.adaptive && task.suggested_interval && task.suggested_interval !== task.interval_days;
const hasSeasonal = ctx.features.seasonal && task.seasonal_factor && task.seasonal_factor !== 1.0;
const hasLeftColumn = hasRecommendation || hasSeasonal;
// Analysis content: Weibull/Seasonal expanded (only when data is available)
const hasWeibullData = ctx.features.adaptive
&& task.interval_analysis?.weibull_beta != null
&& task.interval_analysis?.weibull_eta != null;
const hasSeasonalData = ctx.features.seasonal
&& (task.seasonal_factors?.length === 12
|| task.interval_analysis?.seasonal_factors?.length === 12);
return html`
<div class="tab-content overview-tab">
${renderKPIBar(task, ctx)}
${renderTaskMeta(task, ctx)}
${task.battery_fleet_task
? html`<maintenance-battery-fleet-section .hass=${ctx.hass}></maintenance-battery-fleet-section>`
: html`
${renderDaysProgress(task, ctx.lang)}
${renderTriggerSection(task, ctx.sparkline)}
${renderPredictionSection(task, L, ctx.features)}
`}
<div class="two-column-layout ${hasLeftColumn ? '' : 'single-column'}">
${hasLeftColumn ? html`
<div class="left-column">
${renderRecommendationCard(task, ctx)}
${renderSeasonalCardCompact(task, L, ctx.features)}
</div>
` : nothing}
<div class="right-column">
${renderCostDurationCard(task, L, ctx.costDurationToggle, (v) => ctx.setCostDurationToggle(v))}
</div>
</div>
${hasWeibullData
? collapsible("weibull", "weibull_reliability_curve", renderWeibullSection(task, L), ctx)
: nothing}
${hasSeasonalData
? collapsible("seasonal", "seasonal_chart_title", html`
${renderSeasonalCardExpanded(task, L)}
<div class="seasonal-actions">
<ha-button appearance="plain" @click=${() => ctx.openSeasonalOverrides(task)}>
${t("edit_seasonal_overrides", L)}
</ha-button>
</div>
`, ctx)
: nothing}
${renderChecklistCard(task, ctx)}
${renderRecentActivities(task, ctx)}
</div>
`;
}
function renderHistoryTab(task: MaintenanceTask, ctx: TaskDetailContext) {
return html`
<div class="tab-content history-tab">
${renderHistoryFilters(task, ctx.history)}
${renderHistoryList(task, ctx.history)}
</div>
`;
}
function renderTabContent(task: MaintenanceTask, ctx: TaskDetailContext) {
switch (ctx.activeTab) {
case "overview":
return renderOverviewTab(task, ctx);
case "history":
return renderHistoryTab(task, ctx);
default:
return nothing;
}
}
/** The complete task-detail view. Renders into the PANEL's shadow root (this
* is a render function, not a component) so the panel's dialogs and styles
* keep working unchanged. */
export function renderTaskDetail(task: MaintenanceTask, ctx: TaskDetailContext) {
return html`
<div class="detail-section">
${renderTaskHeader(task, ctx)}
${renderTabBar(ctx)}
${renderTabContent(task, ctx)}
<maintenance-task-documents
.hass=${ctx.hass}
.entryId=${ctx.entryId}
.taskId=${ctx.taskId}
.canWrite=${!ctx.isOperator}
></maintenance-task-documents>
</div>
`;
}