448 lines
19 KiB
TypeScript
448 lines
19 KiB
TypeScript
/** 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 { 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 = task.documentation_url && /^https?:\/\//i.test(task.documentation_url)
|
||
? task.documentation_url : null;
|
||
const safeObjUrl = ctx.objectDocUrl && /^https?:\/\//i.test(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)}
|
||
${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>
|
||
`;
|
||
}
|