393 files
This commit is contained in:
@@ -7,10 +7,10 @@ import { objectRef, parseRef, renderRefChip, taskRef, type HasRef } from "./help
|
||||
import { applySubscriptionEvent, type SubscriptionEvent } from "./helpers/subscription-merge";
|
||||
import { isStaleBundle } from "./helpers/bundle-version";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { syncLocaleFromHass, sharedStyles, STATUS_COLORS, STATUS_ICONS, currencySymbolOf, t, ensureLocale, isLocaleLoaded, formatDate, formatDueDays, formatInterval, formatRecurrence, setProfilePrefs, langOf, formatCost, syncCurrencyDecimals} from "./styles";
|
||||
import { syncLocaleFromHass, sharedStyles, STATUS_COLORS, currencySymbolOf, t, ensureLocale, isLocaleLoaded, formatDate, formatDueDays, formatInterval, formatRecurrence, setProfilePrefs, langOf, formatCost, formatQty, syncCurrencyDecimals} from "./styles";
|
||||
import { OVERVIEW_TABS, type OverviewTab } from "./helpers/overview-tabs";
|
||||
import { LS_KEYS, lsGet, lsSet } from "./helpers/storage-keys";
|
||||
import { openHtmlInNewTab, openSignedDocument, signApiPath } from "./helpers/document-url";
|
||||
import { openHtmlInNewTab, openSignedDocument, preopenTab, signApiPath } from "./helpers/document-url";
|
||||
import { readObjectsCache, writeObjectsCache } from "./helpers/objects-cache";
|
||||
import { hydrateObjects } from "./helpers/hydrate-objects";
|
||||
import { daysProgress } from "./helpers/interval";
|
||||
@@ -21,9 +21,11 @@ import { downloadTextFile } from "./helpers/download";
|
||||
import { renderNotesMarkdown } from "./helpers/notes-markdown";
|
||||
import { buildTaskWorksheetHtml, type WorksheetExcerpt, type WorksheetLabels } from "./helpers/worksheet";
|
||||
import { describePartLink } from "./helpers/shared-parts";
|
||||
import { docDisplayName } from "./helpers/document-categories";
|
||||
import { effectivePhase } from "./helpers/phases";
|
||||
import { renderEventTitles } from "./helpers/event-titles";
|
||||
import { recommendationReason, type HomeProfile, type TemplateRecommendation } from "./helpers/home-profile";
|
||||
import { buildCompleteDialogArgs, fillAndOpenCompleteDialog } from "./helpers/complete-dialog-args";
|
||||
import { describeWsError } from "./ws-errors";
|
||||
import { panelStyles } from "./panel-styles";
|
||||
|
||||
// Global search (#171): what the panel needs to know about a hit — enough
|
||||
@@ -119,10 +121,14 @@ import { renderPersonChip, type PersonDisplay } from "./helpers/person";
|
||||
import "./components/task-detail-view";
|
||||
import { computeWindow, VIRTUAL_MIN_ROWS } from "./helpers/virtual-window";
|
||||
import { INITIAL_STICKY, nextStickyState, stickyStateOnSelect, stickyTop, type StickyState } from "./helpers/sticky-pane";
|
||||
import { invalidateSettingsCache } from "./helpers/settings-cache";
|
||||
import { invalidateSettingsCache, parseSettings, type SettingsWire } from "./helpers/settings-cache";
|
||||
import { canWrite } from "./helpers/permissions";
|
||||
import { renderStatusBadge } from "./renderers/status";
|
||||
import { TOAST_MS, ACTION_TOAST_MS } from "./helpers/toast";
|
||||
import { buildHistoryEntryDraft } from "./helpers/history-draft";
|
||||
import { readingSlotDelta } from "./helpers/reading-slots";
|
||||
import { runWs } from "./helpers/ws-run";
|
||||
import { bulkResultMessage, runWs, runWsEach } from "./helpers/ws-run";
|
||||
import { STATUS_ORDER, statusRank } from "./status-constants";
|
||||
|
||||
type View = "overview" | "object" | "task" | "all_objects" | "all_parts";
|
||||
|
||||
@@ -161,8 +167,10 @@ type GroupByMode = "none" | "area" | "group" | "user" | "object";
|
||||
const SORT_MODES: readonly SortMode[] = ["due_date", "object", "type", "task_name", "area", "assigned_user", "group"];
|
||||
const GROUP_BY_MODES: readonly GroupByMode[] = ["none", "area", "group", "user", "object"];
|
||||
// (OVERVIEW_TABS: imported from helpers/overview-tabs.ts.)
|
||||
/** The dashboard list's status filter options (the <select> in the toolbar). */
|
||||
const STATUS_FILTERS: readonly string[] = ["overdue", "due_soon", "triggered", "ok"];
|
||||
/** The dashboard list's status filter options (the <select> in the toolbar)
|
||||
* — in the urgency order every list sorts by (it offered due soon before
|
||||
* triggered, the one copy out of line; DRY audit 2026-09-26). */
|
||||
const STATUS_FILTERS: readonly string[] = STATUS_ORDER;
|
||||
/** #179: the object page's collapsible sections — the ids saved under
|
||||
* LS_KEYS.objectSections and accepted by the `?section=` deep link. */
|
||||
type ObjectSection = "tasks" | "documents" | "parts" | "history";
|
||||
@@ -363,7 +371,8 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
private _searchSeq = 0;
|
||||
// v2.15.0: template gallery (surfaces the config-flow object templates).
|
||||
@state() private _templateGalleryOpen = false;
|
||||
@state() private _templates: Array<{ id: string; name: string; category: string; tasks: unknown[]; disabled?: boolean }> = [];
|
||||
@state() private _templates: Array<{ id: string; name: string; category: string; tasks: unknown[]; disabled?: boolean } & TemplateRecommendation> = [];
|
||||
@state() private _homeProfile: HomeProfile | null = null;
|
||||
@state() private _templateCategories: Record<string, { icon?: string; [k: string]: unknown }> = {};
|
||||
@state() private _templateBusy = false;
|
||||
// v1.5.0: Calendar tab state
|
||||
@@ -387,7 +396,9 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
* - admins (incl. the owner) always see the full panel
|
||||
* - a non-admin sees the full panel only when operator-write delegation is
|
||||
* enabled AND their ID is in the `admin_panel_user_ids` allowlist
|
||||
* - everyone else sees operator mode (Complete / Skip only)
|
||||
* - everyone else sees operator mode (the household actions only:
|
||||
* Complete / Skip / Reset / Postpone / Snooze — helpers/permissions
|
||||
* HOUSEHOLD_ACTIONS, read tier on the server)
|
||||
*
|
||||
* Delegation defaults OFF, so out of the box every non-admin is read-only.
|
||||
* The switch + allowlist are managed by an admin under Settings → Panel
|
||||
@@ -395,10 +406,11 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
* server mirrors this exact rule in helpers/permissions.user_may_write.
|
||||
*/
|
||||
private get _isOperator(): boolean {
|
||||
const u = this.hass?.user;
|
||||
if (!u) return true; // pre-hass state: render safe default
|
||||
if (u.is_admin) return false;
|
||||
return !(this._operatorWriteEnabled && this._adminPanelUserIds.includes(u.id));
|
||||
// No user yet (pre-hass state) → read-only, the safe default.
|
||||
return !canWrite(this.hass?.user, {
|
||||
operatorWriteEnabled: this._operatorWriteEnabled,
|
||||
operatorIds: this._adminPanelUserIds,
|
||||
});
|
||||
}
|
||||
|
||||
private _popstateHandler = (e: PopStateEvent) => this._onPopState(e);
|
||||
@@ -760,25 +772,17 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
if (budgetResult) { this._budget = budgetResult as BudgetStatus; syncCurrencyDecimals(this._budget); }
|
||||
if (groupsResult) this._groups = (groupsResult as { groups: Record<string, MaintenanceGroup> }).groups || {};
|
||||
if (settingsResult) {
|
||||
const sr = settingsResult as {
|
||||
features: AdvancedFeatures;
|
||||
admin_panel_user_ids?: string[];
|
||||
operator_write_enabled?: boolean;
|
||||
general?: { default_warning_days?: number; row_action_style?: string; row_action_notice_pending?: boolean; ref_numbers_in_lists?: boolean };
|
||||
objects_table_columns?: string[];
|
||||
};
|
||||
this._features = sr.features;
|
||||
this._adminPanelUserIds = sr.admin_panel_user_ids || [];
|
||||
this._operatorWriteEnabled = sr.operator_write_enabled ?? false;
|
||||
const dwd = sr.general?.default_warning_days;
|
||||
if (typeof dwd === "number" && dwd >= 0 && dwd <= 365) {
|
||||
this._defaultWarningDays = dwd;
|
||||
}
|
||||
const ras = sr.general?.row_action_style;
|
||||
this._rowActionStyle = ras === "icons" || ras === "buttons" ? ras : "buttons_compact";
|
||||
this._rowActionNotice = sr.general?.row_action_notice_pending === true;
|
||||
this._refsInLists = sr.general?.ref_numbers_in_lists === true;
|
||||
this._objectsTableColumns = sanitizeColumns(sr.objects_table_columns);
|
||||
// The one settings parser (helpers/settings-cache) — the panel kept its
|
||||
// own copy of the field rules (DRY audit 2026-09-26).
|
||||
const s = parseSettings(settingsResult as SettingsWire);
|
||||
this._features = s.features;
|
||||
this._adminPanelUserIds = [...s.access.operatorIds];
|
||||
this._operatorWriteEnabled = s.access.operatorWriteEnabled;
|
||||
this._defaultWarningDays = s.defaultWarningDays;
|
||||
this._rowActionStyle = s.rowActionStyle;
|
||||
this._rowActionNotice = s.rowActionNoticePending;
|
||||
this._refsInLists = s.refsInLists;
|
||||
this._objectsTableColumns = sanitizeColumns(s.objectsTableColumns);
|
||||
}
|
||||
|
||||
// Fetch mini-sparkline data for overview (non-blocking)
|
||||
@@ -993,8 +997,17 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
}
|
||||
this._showTask(entryId, taskId);
|
||||
if (action === "complete") {
|
||||
// The printed "Complete" QR IS the scan (docs: proof of presence) —
|
||||
// without via_tag_scan a require_tag_scan task refused the very
|
||||
// completion its own sticker opened (bug audit 2026-09-26).
|
||||
requestAnimationFrame(() => {
|
||||
this._openCompleteDialog(entryId, taskId, task.name, this._features.checklists ? task.checklist : undefined, this._features.adaptive && !!task.adaptive_config?.enabled);
|
||||
this._openCompleteDialog(entryId, taskId, task.name, this._features.checklists ? task.checklist : undefined, this._features.adaptive && !!task.adaptive_config?.enabled, { viaTagScan: true });
|
||||
});
|
||||
} else if (action === "skip") {
|
||||
// The settings view prints "Skip" QR codes too; the scan used to
|
||||
// land on the plain task page with no skip prompt.
|
||||
requestAnimationFrame(() => {
|
||||
if (task.allow_skip !== false) void this._promptSkipTask(entryId, taskId);
|
||||
});
|
||||
} else if (action === "quick_complete") {
|
||||
// v1.3.0: silent complete using pre-configured defaults; falls back
|
||||
@@ -1014,9 +1027,17 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
return type === "counter" || type === "state_change";
|
||||
}
|
||||
|
||||
/** Newest request per entity — quick range-chip clicks (7 → 30 → 90) put
|
||||
* several fetches in flight, and a slower OLDER answer landed last and
|
||||
* showed the wrong window under the new chip (bug audit 2026-09-26). */
|
||||
private _detailStatsSeq = new Map<string, number>();
|
||||
|
||||
private async _fetchDetailStats(entityId: string, isCounter: boolean): Promise<void> {
|
||||
if (!this._statsService) return;
|
||||
const seq = (this._detailStatsSeq.get(entityId) ?? 0) + 1;
|
||||
this._detailStatsSeq.set(entityId, seq);
|
||||
const points = await this._statsService.getDetailStats(entityId, isCounter, this._chartRangeDays);
|
||||
if (this._detailStatsSeq.get(entityId) !== seq) return;
|
||||
const updated = new Map(this._detailStatsData);
|
||||
updated.set(entityId, points);
|
||||
this._detailStatsData = updated;
|
||||
@@ -1185,6 +1206,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
status: task.status,
|
||||
days_until_due: task.days_until_due ?? null,
|
||||
next_due: task.next_due ?? null,
|
||||
next_event_titles: task.next_event_titles ?? [],
|
||||
trigger_active: task.trigger_active,
|
||||
trigger_current_value: task.trigger_current_value ?? null,
|
||||
trigger_current_delta: task.trigger_current_delta ?? null,
|
||||
@@ -1208,8 +1230,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
});
|
||||
}
|
||||
}
|
||||
const statusOrder: Record<string, number> = { overdue: 0, triggered: 1, due_soon: 2, ok: 3 };
|
||||
const byStatus = (a: TaskRow, b: TaskRow) => (statusOrder[a.status] ?? 9) - (statusOrder[b.status] ?? 9);
|
||||
const byStatus = (a: TaskRow, b: TaskRow) => statusRank(a.status) - statusRank(b.status);
|
||||
const byDays = (a: TaskRow, b: TaskRow) => (a.days_until_due ?? 99999) - (b.days_until_due ?? 99999);
|
||||
const byDue = (a: TaskRow, b: TaskRow) => byStatus(a, b) || byDays(a, b);
|
||||
const areaName = (r: TaskRow) =>
|
||||
@@ -1552,7 +1573,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
this._toastUndo = null;
|
||||
this._toastActionLabel = "";
|
||||
this._toastMessage = msg;
|
||||
this._toastTimer = setTimeout(() => { this._toastMessage = ""; this._toastTimer = null; }, 4000);
|
||||
this._toastTimer = setTimeout(() => { this._toastMessage = ""; this._toastTimer = null; }, TOAST_MS);
|
||||
}
|
||||
|
||||
/** A toast with an action button (label defaults to Undo). Used for
|
||||
@@ -1571,7 +1592,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
this._toastUndo = undo;
|
||||
this._toastTimer = setTimeout(() => {
|
||||
this._toastMessage = ""; this._toastUndo = null; this._toastTimer = null;
|
||||
}, 7000);
|
||||
}, ACTION_TOAST_MS);
|
||||
}
|
||||
|
||||
private _runToastUndo(): void {
|
||||
@@ -1747,7 +1768,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
out.push({
|
||||
kind: d.match === "content" ? "content" : "document",
|
||||
entryId: d.entry_id, docId: d.id, docKind: d.kind, url: d.url, page: d.page ?? null,
|
||||
label: d.title || d.filename || d.url || "", sub: d.object_name || "", snippet: d.snippet || "",
|
||||
label: docDisplayName(d), sub: d.object_name || "", snippet: d.snippet || "",
|
||||
score: d.score, icon: d.kind === "weblink" ? "mdi:link-variant" : "mdi:file-document-outline",
|
||||
});
|
||||
}
|
||||
@@ -1886,23 +1907,25 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
// --- Suggested setups (integration signatures, v2.28) ---
|
||||
|
||||
private async _setupBatteryFleet(): Promise<void> {
|
||||
try {
|
||||
const res = await this.hass.connection.sendMessagePromise<{ entry_id: string; task_id?: string }>({
|
||||
type: "maintenance_supporter/battery_fleet/setup",
|
||||
language: this.hass.language || "en",
|
||||
});
|
||||
this._batteryFleetSetupAvailable = false;
|
||||
await this._loadData();
|
||||
// Jump to the fleet task so the user lands on its battery detail section.
|
||||
const obj = this._objects.find((o) => o.entry_id === res.entry_id);
|
||||
const tk = obj?.tasks.find((t2) => t2.id === res.task_id) || obj?.tasks[0];
|
||||
if (obj && tk) {
|
||||
this._showTask(obj.entry_id, tk.id);
|
||||
}
|
||||
this._showToast(t("battery_fleet_setup_done", this._lang));
|
||||
} catch (e) {
|
||||
this._showToast(describeWsError(e, this._lang));
|
||||
const res = await runWs<{ entry_id: string; task_id?: string }>(
|
||||
this,
|
||||
{ type: "maintenance_supporter/battery_fleet/setup", language: this.hass.language || "en" },
|
||||
{
|
||||
reload: async () => {
|
||||
this._batteryFleetSetupAvailable = false;
|
||||
await this._loadData();
|
||||
},
|
||||
onError: (m) => this._showToast(m),
|
||||
},
|
||||
);
|
||||
if (res === undefined) return;
|
||||
// Jump to the fleet task so the user lands on its battery detail section.
|
||||
const obj = this._objects.find((o) => o.entry_id === res?.entry_id);
|
||||
const tk = obj?.tasks.find((t2) => t2.id === res?.task_id) || obj?.tasks[0];
|
||||
if (obj && tk) {
|
||||
this._showTask(obj.entry_id, tk.id);
|
||||
}
|
||||
this._showToast(t("battery_fleet_setup_done", this._lang));
|
||||
}
|
||||
|
||||
private _openSuggestedSetups(): void {
|
||||
@@ -1920,37 +1943,39 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
|
||||
private async _openTemplateGallery(): Promise<void> {
|
||||
this._templateGalleryOpen = true;
|
||||
if (this._templates.length > 0) return;
|
||||
try {
|
||||
const res = await this.hass.connection.sendMessagePromise<{
|
||||
categories: Record<string, { icon?: string }>;
|
||||
templates: Array<{ id: string; name: string; category: string; tasks: unknown[]; disabled?: boolean }>;
|
||||
}>({ type: "maintenance_supporter/templates", language: this._lang });
|
||||
this._templateCategories = res.categories || {};
|
||||
// v2.21: admin-hidden templates stay out of the gallery.
|
||||
this._templates = (res.templates || []).filter((tpl) => !tpl.disabled);
|
||||
} catch {
|
||||
this._showToast(t("action_error", this._lang));
|
||||
}
|
||||
// Refetched on every open (the last list shows meanwhile): the home
|
||||
// profile behind the recommendations can change in Settings. A refusal
|
||||
// toasts the server's reason, like every _runAction path (DRY audit
|
||||
// 2026-09-26).
|
||||
const res = await runWs<{
|
||||
categories: Record<string, { icon?: string }>;
|
||||
templates: Array<{ id: string; name: string; category: string; tasks: unknown[]; disabled?: boolean } & TemplateRecommendation>;
|
||||
profile?: HomeProfile;
|
||||
}>(this, { type: "maintenance_supporter/templates", language: this._lang }, { onError: (m) => this._showToast(m) });
|
||||
if (res === undefined) return;
|
||||
this._templateCategories = res?.categories || {};
|
||||
// v2.21: admin-hidden templates stay out of the gallery.
|
||||
this._templates = (res?.templates || []).filter((tpl) => !tpl.disabled);
|
||||
this._homeProfile = res?.profile ?? null;
|
||||
}
|
||||
|
||||
private async _createFromTemplate(templateId: string): Promise<void> {
|
||||
this._templateBusy = true;
|
||||
try {
|
||||
const res = await this.hass.connection.sendMessagePromise<{ entry_id?: string }>({
|
||||
type: "maintenance_supporter/object/from_template",
|
||||
language: this._lang,
|
||||
template_id: templateId,
|
||||
});
|
||||
this._templateGalleryOpen = false;
|
||||
await this._loadData();
|
||||
this._showToast(t("template_created", this._lang));
|
||||
if (res?.entry_id) this._showObject(res.entry_id);
|
||||
} catch {
|
||||
this._showToast(t("action_error", this._lang));
|
||||
} finally {
|
||||
this._templateBusy = false;
|
||||
}
|
||||
// e.g. "Limit reached" / a create_failed reason instead of "Action failed".
|
||||
const res = await runWs<{ entry_id?: string }>(
|
||||
this,
|
||||
{ type: "maintenance_supporter/object/from_template", language: this._lang, template_id: templateId },
|
||||
{
|
||||
busy: (b) => { this._templateBusy = b; },
|
||||
reload: async () => {
|
||||
this._templateGalleryOpen = false;
|
||||
await this._loadData();
|
||||
},
|
||||
successToast: t("template_created", this._lang),
|
||||
onSuccess: (m) => this._showToast(m),
|
||||
onError: (m) => this._showToast(m),
|
||||
},
|
||||
);
|
||||
if (res?.entry_id) this._showObject(res.entry_id);
|
||||
}
|
||||
|
||||
private _categoryName(catId: string): string {
|
||||
@@ -1959,15 +1984,44 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
return cat[`name_${this._lang}`] || cat["name_en"] || catId;
|
||||
}
|
||||
|
||||
private _renderTemplateCard(tpl: (typeof this._templates)[number], withReasons = false) {
|
||||
const L = this._lang;
|
||||
const country = this._homeProfile?.country ?? null;
|
||||
return html`
|
||||
<button class="template-card ${tpl.dwelling_mismatch ? "not-typical" : ""}" .disabled=${this._templateBusy}
|
||||
title=${tpl.dwelling_mismatch ? t("templates_not_typical", L) : ""}
|
||||
@click=${() => this._createFromTemplate(tpl.id)}>
|
||||
<span class="template-card-name">${tpl.name}</span>
|
||||
<span class="template-card-count">${tpl.tasks.length === 1
|
||||
? t("templates_task_count_one", L)
|
||||
: t("templates_task_count", L).replace("{n}", String(tpl.tasks.length))}</span>
|
||||
${withReasons && tpl.reasons?.length
|
||||
? html`<span class="template-card-reasons">
|
||||
${tpl.reasons.map((r) => html`<span class="template-card-reason">${recommendationReason(r, L, country)}</span>`)}
|
||||
</span>`
|
||||
: nothing}
|
||||
${tpl.set_up
|
||||
? html`<span class="template-card-setup"><ha-icon icon="mdi:check-circle-outline"></ha-icon>${t("templates_set_up", L)}</span>`
|
||||
: nothing}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderTemplateGallery() {
|
||||
if (!this._templateGalleryOpen) return nothing;
|
||||
const L = this._lang;
|
||||
// Group templates by category, preserving category declaration order.
|
||||
// v2.93: templates untypical for the home's dwelling move to the end of
|
||||
// their category (still available — never hidden).
|
||||
const byCat = new Map<string, typeof this._templates>();
|
||||
for (const tpl of this._templates) {
|
||||
if (!byCat.has(tpl.category)) byCat.set(tpl.category, []);
|
||||
byCat.get(tpl.category)!.push(tpl);
|
||||
}
|
||||
for (const tpls of byCat.values()) tpls.sort((a, b) => Number(!!a.dwelling_mismatch) - Number(!!b.dwelling_mismatch));
|
||||
// 2.94: a template the home already uses is not recommended again (the
|
||||
// server says so too; kept here so an older server answer can't list it).
|
||||
const recommended = this._templates.filter((tpl) => tpl.recommended && !tpl.set_up);
|
||||
return html`
|
||||
<div class="palette-backdrop" @click=${() => { this._templateGalleryOpen = false; }}>
|
||||
<div class="template-gallery" @click=${(e: Event) => e.stopPropagation()}>
|
||||
@@ -1977,6 +2031,21 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
@click=${() => { this._templateGalleryOpen = false; }}></ha-icon-button>
|
||||
</div>
|
||||
<div class="template-gallery-body">
|
||||
<div class="template-legal">
|
||||
<ha-icon icon="mdi:scale-balance"></ha-icon>
|
||||
<span>${t("templates_legal_hint", L)}</span>
|
||||
</div>
|
||||
${recommended.length > 0
|
||||
? html`
|
||||
<div class="template-cat recommended">
|
||||
<div class="template-cat-head">
|
||||
<ha-icon icon="mdi:home-heart"></ha-icon>
|
||||
${t("templates_recommended_title", L)}
|
||||
</div>
|
||||
<div class="template-cat-hint">${t("templates_recommended_hint", L)}</div>
|
||||
<div class="template-grid">${recommended.map((tpl) => this._renderTemplateCard(tpl, true))}</div>
|
||||
</div>`
|
||||
: nothing}
|
||||
${this._templates.length === 0
|
||||
? html`<div class="palette-empty">${t("loading", L)}…</div>`
|
||||
: [...byCat.entries()].map(([catId, tpls]) => html`
|
||||
@@ -1985,15 +2054,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
<ha-icon icon="${(this._templateCategories[catId]?.icon as string) || "mdi:folder-outline"}"></ha-icon>
|
||||
${this._categoryName(catId)}
|
||||
</div>
|
||||
<div class="template-grid">
|
||||
${tpls.map((tpl) => html`
|
||||
<button class="template-card" .disabled=${this._templateBusy}
|
||||
@click=${() => this._createFromTemplate(tpl.id)}>
|
||||
<span class="template-card-name">${tpl.name}</span>
|
||||
<span class="template-card-count">${t("templates_task_count", L).replace("{n}", String(tpl.tasks.length))}</span>
|
||||
</button>
|
||||
`)}
|
||||
</div>
|
||||
<div class="template-grid">${tpls.map((tpl) => this._renderTemplateCard(tpl))}</div>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
@@ -2027,30 +2088,47 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
this._bulkSelected = allSelected ? new Set() : new Set(keys);
|
||||
}
|
||||
|
||||
/** Run one WS message per selected row (the endpoints are per-task); reports
|
||||
* how many succeeded and refreshes once at the end. */
|
||||
private async _runBulk(
|
||||
/** THE bulk loop of both selection modes (task rows, objects): one WS call
|
||||
* per item (helpers/ws-run runWsEach — the endpoints are per item), ONE
|
||||
* refresh at the end, and one toast with the successful count plus how
|
||||
* many failed and why. The task and the object loop were identical
|
||||
* copies that swallowed every reason (DRY audit 2026-09-26). The undo
|
||||
* receives the items that went through. */
|
||||
private async _runBulkItems<K>(
|
||||
items: K[],
|
||||
build: (item: K) => Record<string, unknown>,
|
||||
doneMsg: (n: number) => string,
|
||||
endSelection: () => void,
|
||||
undo?: (done: K[]) => void,
|
||||
): Promise<void> {
|
||||
if (items.length === 0) return;
|
||||
this._actionLoading = true;
|
||||
const { done, failed } = await runWsEach(this, items, build);
|
||||
this._actionLoading = false;
|
||||
endSelection();
|
||||
await this._loadData();
|
||||
const msg = bulkResultMessage(doneMsg(done.length), failed, this._lang);
|
||||
if (undo && done.length > 0) this._showUndoToast(msg, () => undo(done));
|
||||
else this._showToast(msg);
|
||||
}
|
||||
|
||||
/** One WS message per selected task row. */
|
||||
private _runBulk(
|
||||
rows: TaskRow[],
|
||||
build: (row: TaskRow) => Record<string, unknown>,
|
||||
doneMsg: (n: number) => string,
|
||||
undo?: () => void,
|
||||
undo?: (done: TaskRow[]) => void,
|
||||
): Promise<void> {
|
||||
const selected = rows.filter((r) => this._bulkSelected.has(this._bulkKey(r)));
|
||||
if (selected.length === 0) return;
|
||||
this._actionLoading = true;
|
||||
let ok = 0;
|
||||
for (const row of selected) {
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise(build(row));
|
||||
ok++;
|
||||
} catch { /* keep going; report the successful count */ }
|
||||
}
|
||||
this._actionLoading = false;
|
||||
this._bulkSelected = new Set();
|
||||
this._bulkMode = false;
|
||||
await this._loadData();
|
||||
if (undo && ok > 0) this._showUndoToast(doneMsg(ok), undo);
|
||||
else this._showToast(doneMsg(ok));
|
||||
return this._runBulkItems(
|
||||
rows.filter((r) => this._bulkSelected.has(this._bulkKey(r))),
|
||||
build,
|
||||
doneMsg,
|
||||
() => {
|
||||
this._bulkSelected = new Set();
|
||||
this._bulkMode = false;
|
||||
},
|
||||
undo,
|
||||
);
|
||||
}
|
||||
|
||||
/** #188: move every selected task to one object (the single-task move's
|
||||
@@ -2098,24 +2176,18 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
}
|
||||
|
||||
/** One WS call per selected object, sequentially (config entries are
|
||||
* removed one at a time on the backend); the successful count is reported. */
|
||||
private async _runObjBulk(type: string, doneMsg: (n: number) => string, undo?: (ids: string[]) => Promise<void>): Promise<void> {
|
||||
const ids = [...this._objBulkSelected];
|
||||
if (ids.length === 0) return;
|
||||
this._actionLoading = true;
|
||||
const done: string[] = [];
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({ type, entry_id: id });
|
||||
done.push(id);
|
||||
} catch { /* keep going; report the successful count */ }
|
||||
}
|
||||
this._actionLoading = false;
|
||||
this._objBulkSelected = new Set();
|
||||
this._objBulkMode = false;
|
||||
await this._loadData();
|
||||
if (undo && done.length > 0) this._showUndoToast(doneMsg(done.length), () => { void undo(done); });
|
||||
else this._showToast(doneMsg(done.length));
|
||||
* removed one at a time on the backend). */
|
||||
private _runObjBulk(type: string, doneMsg: (n: number) => string, undo?: (ids: string[]) => Promise<void>): Promise<void> {
|
||||
return this._runBulkItems(
|
||||
[...this._objBulkSelected],
|
||||
(id) => ({ type, entry_id: id }),
|
||||
doneMsg,
|
||||
() => {
|
||||
this._objBulkSelected = new Set();
|
||||
this._objBulkMode = false;
|
||||
},
|
||||
undo ? (done) => { void undo(done); } : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
private async _objBulkDelete(): Promise<void> {
|
||||
@@ -2178,15 +2250,13 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
}
|
||||
|
||||
private _bulkArchive(rows: TaskRow[]): void {
|
||||
// Capture the selection so the undo can unarchive exactly those.
|
||||
const keys = rows.filter((r) => this._bulkSelected.has(this._bulkKey(r)))
|
||||
.map((r) => ({ entry_id: r.entry_id, task_id: r.task_id }));
|
||||
void this._runBulk(
|
||||
rows,
|
||||
(row) => ({ type: "maintenance_supporter/task/archive", entry_id: row.entry_id, task_id: row.task_id }),
|
||||
(n) => t("bulk_archived", this._lang).replace("{n}", String(n)),
|
||||
async () => {
|
||||
for (const k of keys) {
|
||||
// The undo unarchives exactly the rows that were archived.
|
||||
async (done) => {
|
||||
for (const k of done) {
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/task/unarchive", entry_id: k.entry_id, task_id: k.task_id,
|
||||
@@ -2209,25 +2279,22 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
msg: Record<string, unknown>,
|
||||
opts?: { successToast?: string },
|
||||
): Promise<T | null> {
|
||||
this._actionLoading = true;
|
||||
try {
|
||||
const res = await this.hass.connection.sendMessagePromise<T>(msg);
|
||||
await this._loadData();
|
||||
if (opts?.successToast) this._showToast(opts.successToast);
|
||||
return (res ?? {}) as T;
|
||||
} catch (e) {
|
||||
this._showToast(describeWsError(e, this._lang));
|
||||
return null;
|
||||
} finally {
|
||||
this._actionLoading = false;
|
||||
}
|
||||
// helpers/ws-run runWs bound to the panel's busy flag, reload and toast.
|
||||
const res = await runWs<T>(this, msg, {
|
||||
busy: (b) => { this._actionLoading = b; },
|
||||
reload: () => this._loadData(),
|
||||
successToast: opts?.successToast,
|
||||
onSuccess: (m) => this._showToast(m),
|
||||
onError: (m) => this._showToast(m),
|
||||
});
|
||||
return res === undefined ? null : ((res ?? {}) as T);
|
||||
}
|
||||
|
||||
private async _deleteObject(entryId: string): Promise<void> {
|
||||
const dlg = this.shadowRoot!.querySelector<MaintenanceConfirmDialog>("maintenance-confirm-dialog");
|
||||
const ok = await dlg?.confirm({
|
||||
title: t("delete", this._lang),
|
||||
message: t("confirm_delete_object", this._lang),
|
||||
message: t("delete_object_confirm", this._lang),
|
||||
confirmText: t("delete", this._lang),
|
||||
danger: true,
|
||||
});
|
||||
@@ -2291,7 +2358,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
const dlg = this.shadowRoot!.querySelector<MaintenanceConfirmDialog>("maintenance-confirm-dialog");
|
||||
const ok = await dlg?.confirm({
|
||||
title: t("delete", this._lang),
|
||||
message: t("confirm_delete_task", this._lang),
|
||||
message: t("delete_task_confirm", this._lang),
|
||||
confirmText: t("delete", this._lang),
|
||||
danger: true,
|
||||
});
|
||||
@@ -2557,14 +2624,17 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
private async _handleQuickComplete(
|
||||
entryId: string, taskId: string, task: MaintenanceTask,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/task/quick_complete",
|
||||
entry_id: entryId, task_id: taskId,
|
||||
});
|
||||
this._showToast(t("quick_complete_success", this._lang));
|
||||
} catch (e: unknown) {
|
||||
const code = (e as { code?: string })?.code || "";
|
||||
let failure: { message: string; code: string } | null = null;
|
||||
const res = await runWs(
|
||||
this,
|
||||
{ type: "maintenance_supporter/task/quick_complete", entry_id: entryId, task_id: taskId },
|
||||
{
|
||||
fallbackKey: "action_error",
|
||||
onError: (message, e) => { failure = { message, code: (e as { code?: string })?.code || "" }; },
|
||||
},
|
||||
);
|
||||
if (res === undefined) {
|
||||
const { message, code } = failure ?? { message: "", code: "" };
|
||||
// Both refusals mean "the dialog has to collect something": no
|
||||
// quick-complete defaults configured, or the task demands completion
|
||||
// details a silent complete cannot supply. The dialog opens as the
|
||||
@@ -2579,10 +2649,11 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
} else {
|
||||
// Relay the server's reason (too_early carries the earliest date)
|
||||
// instead of a generic "action failed".
|
||||
this._showToast(describeWsError(e, this._lang, t("action_error", this._lang)));
|
||||
this._showToast(message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
this._showToast(t("quick_complete_success", this._lang));
|
||||
// Silent success: the list must reflect the completion right away — the
|
||||
// subscription delta may lag or be coalesced.
|
||||
try { await this._loadData(); } catch { /* subscription will sync */ }
|
||||
@@ -2595,6 +2666,11 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
const obj = this._getObject(entryId);
|
||||
const task = obj?.tasks.find((tk) => tk.id === taskId);
|
||||
if (!obj || !task) return;
|
||||
// The tab opens NOW, in the click's synchronous part — after the QR /
|
||||
// documents awaits below a window.open is no gesture any more and
|
||||
// popup blockers swallowed the sheet (bug audit 2026-09-26 #2).
|
||||
const tab = preopenTab();
|
||||
let shown = false;
|
||||
this._actionLoading = true;
|
||||
try {
|
||||
const qrBase: Record<string, unknown> = {
|
||||
@@ -2628,7 +2704,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
),
|
||||
};
|
||||
excerpt = {
|
||||
title: manual.title || manual.filename || "Manual",
|
||||
title: docDisplayName(manual) || "Manual",
|
||||
startPage: start, endPage: start + count - 1,
|
||||
// Absolute URL: the sheet lives on a blob: page, where a
|
||||
// relative /api/... href cannot resolve (caught live).
|
||||
@@ -2677,8 +2753,10 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
partsLines,
|
||||
taskRef(obj.object, task),
|
||||
);
|
||||
openHtmlInNewTab(html);
|
||||
openHtmlInNewTab(html, tab);
|
||||
shown = true;
|
||||
} finally {
|
||||
if (!shown) tab?.close();
|
||||
this._actionLoading = false;
|
||||
}
|
||||
}
|
||||
@@ -2696,24 +2774,47 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
/** Ticks not yet confirmed by a reload, per task — and the chain that
|
||||
* sends them one after another. Two quick ticks each built the FULL
|
||||
* state from the not-yet-reloaded task, so the second send wrote the
|
||||
* first item back to unticked (bug audit 2026-09-26). Now every send is
|
||||
* built at send time from the fresh task PLUS the pending ticks, and the
|
||||
* sends are serialized so the server applies them in click order. */
|
||||
private _checklistPending = new Map<string, Record<string, boolean>>();
|
||||
private _checklistChain: Promise<unknown> = Promise.resolve();
|
||||
|
||||
/** #73: persist one checklist tick. Sends the FULL current state (the
|
||||
* server replaces, not merges — idempotent) and reloads so the progress
|
||||
* header and any other open surface agree. */
|
||||
private async _setChecklistItem(entryId: string, taskId: string, item: string, done: boolean): Promise<void> {
|
||||
const obj = this._getObject(entryId);
|
||||
const task = obj?.tasks.find((x) => x.id === taskId);
|
||||
if (!task) return;
|
||||
const state: Record<string, boolean> = {};
|
||||
// #139: a phased task ticks the checklist of the phase currently due.
|
||||
const steps = effectivePhase(task)?.checklist ?? (task.checklist || []);
|
||||
for (const step of steps) {
|
||||
const current = task.checklist_progress?.[step] ?? false;
|
||||
state[step] = step === item ? done : current;
|
||||
}
|
||||
await this._runAction({
|
||||
type: "maintenance_supporter/task/checklist_progress",
|
||||
entry_id: entryId, task_id: taskId, checklist_state: state,
|
||||
private _setChecklistItem(entryId: string, taskId: string, item: string, done: boolean): Promise<void> {
|
||||
const key = `${entryId}/${taskId}`;
|
||||
this._checklistPending.set(key, { ...(this._checklistPending.get(key) ?? {}), [item]: done });
|
||||
const run = this._checklistChain.then(async () => {
|
||||
const task = this._getObject(entryId)?.tasks.find((x) => x.id === taskId);
|
||||
if (!task) return;
|
||||
const pending = this._checklistPending.get(key) ?? {};
|
||||
const state: Record<string, boolean> = {};
|
||||
// #139: a phased task ticks the checklist of the phase currently due.
|
||||
const steps = effectivePhase(task)?.checklist ?? (task.checklist || []);
|
||||
for (const step of steps) {
|
||||
state[step] = step in pending ? pending[step] : (task.checklist_progress?.[step] ?? false);
|
||||
}
|
||||
await this._runAction({
|
||||
type: "maintenance_supporter/task/checklist_progress",
|
||||
entry_id: entryId, task_id: taskId, checklist_state: state,
|
||||
});
|
||||
// _runAction reloaded: the ticks sent so far are in the task now —
|
||||
// drop exactly those, keep any tick made while this send was out.
|
||||
const now = this._checklistPending.get(key);
|
||||
if (now) {
|
||||
for (const [step, value] of Object.entries(state)) {
|
||||
if (step in now && now[step] === value) delete now[step];
|
||||
}
|
||||
if (Object.keys(now).length === 0) this._checklistPending.delete(key);
|
||||
}
|
||||
});
|
||||
this._checklistChain = run.catch(() => undefined);
|
||||
return run;
|
||||
}
|
||||
|
||||
private _openCompleteDialog(
|
||||
@@ -2744,7 +2845,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
objects: this._objects,
|
||||
lang: this._lang,
|
||||
checklist,
|
||||
checklistsEnabled: this._features.checklists,
|
||||
features: this._features,
|
||||
adaptiveEnabled,
|
||||
currencySymbol: this._currencySymbol,
|
||||
viaTagScan: opts?.viaTagScan,
|
||||
@@ -3048,11 +3149,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
* is its own span so narrow/tight rows can drop it (#150: the pill ate a
|
||||
* third of a phone row) while `title`/`aria-label` keep the text reachable. */
|
||||
private _statusBadge(archived: boolean, isDone: boolean, status: string) {
|
||||
const L = this._lang;
|
||||
const cls = archived ? "archived" : (isDone ? "done" : status);
|
||||
const iconKey = archived ? "archived" : (isDone ? "completed" : status);
|
||||
const label = archived ? t("archived", L) : (isDone ? t("completed", L) : t(status, L));
|
||||
return html`<span class="status-badge ${cls}" role="img" title="${label}" aria-label="${label}"><ha-icon icon="${STATUS_ICONS[iconKey] || "mdi:circle-medium"}"></ha-icon><span class="status-label">${label}</span></span>`;
|
||||
return renderStatusBadge({ archived, is_done: isDone, status }, this._lang);
|
||||
}
|
||||
|
||||
private _setOverviewTab(tab: OverviewTab): void {
|
||||
@@ -3111,7 +3208,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
<div class="today-row" @click=${() => this._showTask(row.entry_id, row.task_id)}>
|
||||
<span class="today-dot ${row.trigger_active ? "triggered" : row.status}"></span>
|
||||
<div class="today-main">
|
||||
<div class="today-task">${this._listRef(row.entry_id, row.task_id)}${row.task_name}</div>
|
||||
<div class="today-task">${this._listRef(row.entry_id, row.task_id)}${row.task_name}${renderEventTitles(row.next_event_titles)}</div>
|
||||
<div class="today-object">
|
||||
<span class="today-object-text">${row.object_name} · ${formatDueDays(row.days_until_due, L)}</span>
|
||||
${renderPersonChip(personOf(row), "today-person")}
|
||||
@@ -3188,10 +3285,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
@change=${(e: Event) => { this._filterStatus = (e.target as HTMLSelectElement).value; this._activeViewId = ""; }}
|
||||
>
|
||||
<option value="">${t("all", L)}</option>
|
||||
<option value="overdue">${t("overdue", L)}</option>
|
||||
<option value="due_soon">${t("due_soon", L)}</option>
|
||||
<option value="triggered">${t("triggered", L)}</option>
|
||||
<option value="ok">${t("ok", L)}</option>
|
||||
${STATUS_FILTERS.map((s) => html`<option value=${s} ?selected=${this._filterStatus === s}>${t(s, L)}</option>`)}
|
||||
</select>
|
||||
</label>
|
||||
<label class="filter-field">
|
||||
@@ -3600,7 +3694,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
<div class="object-card-header">
|
||||
<span class="object-card-name">${this._objRef(obj.object)}${obj.object.name}</span>
|
||||
${obj.object.paused
|
||||
? html`<span class="paused-badge" title="${t("object_paused_badge", L)}${obj.object.paused_until ? ` — ${obj.object.paused_until}` : ""}">
|
||||
? html`<span class="paused-badge" title="${t("object_paused_badge", L)}${obj.object.paused_until ? ` — ${formatDate(obj.object.paused_until, L)}` : ""}">
|
||||
<ha-icon icon="mdi:pause-circle-outline"></ha-icon>
|
||||
</span>`
|
||||
: nothing}
|
||||
@@ -3609,7 +3703,9 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
<ha-icon icon="mdi:paperclip"></ha-icon>${obj.object.document_count}
|
||||
</span>`
|
||||
: nothing}
|
||||
<span class="object-card-count">${obj.tasks.length} ${t("tasks_lower", L)}</span>
|
||||
<span class="object-card-count">${obj.tasks.length === 1
|
||||
? t("templates_task_count_one", L)
|
||||
: t("templates_task_count", L).replace("{n}", String(obj.tasks.length))}</span>
|
||||
</div>
|
||||
${obj.object.manufacturer || obj.object.model
|
||||
? html`<div class="object-card-meta">${[obj.object.manufacturer, obj.object.model].filter(Boolean).join(" ")}</div>`
|
||||
@@ -3776,7 +3872,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
: nothing}
|
||||
</td>
|
||||
<td>${row.object_name || "—"}</td>
|
||||
<td>${row.stock !== null ? `${row.stock}${row.unit ? ` ${row.unit}` : ""}` : "—"}</td>
|
||||
<td>${row.stock !== null ? formatQty(row.stock, row.unit, L) : "—"}</td>
|
||||
<td>${row.reorder_threshold ?? "—"}</td>
|
||||
<td>${row.cost != null ? formatCost(row.cost, currency, L) : "—"}</td>
|
||||
<td>${row.storage_location || "—"}</td>
|
||||
@@ -4002,13 +4098,12 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
|
||||
private async _deleteGroup(groupId: string, name: string): Promise<void> {
|
||||
const dlg = this.shadowRoot!.querySelector<MaintenanceConfirmDialog>("maintenance-confirm-dialog");
|
||||
const ok = dlg
|
||||
? await dlg.confirm({
|
||||
title: t("delete_group", this._lang),
|
||||
message: t("delete_group_confirm", this._lang).replace("{name}", name),
|
||||
confirmText: t("delete", this._lang),
|
||||
})
|
||||
: confirm(`${t("delete_group_confirm", this._lang).replace("{name}", name)}`);
|
||||
const ok = await dlg?.confirm({
|
||||
title: t("delete_group", this._lang),
|
||||
message: t("delete_group_confirm", this._lang).replace("{name}", name),
|
||||
confirmText: t("delete", this._lang),
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
await this._runAction({
|
||||
type: "maintenance_supporter/group/delete",
|
||||
@@ -4089,7 +4184,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
</span>
|
||||
<span class="row-head">
|
||||
<span class="cell object-name" @click=${(e: Event) => { e.stopPropagation(); this._showObject(row.entry_id); }}>${row.object_name}</span>
|
||||
<span class="cell task-name" @click=${() => this._showTask(row.entry_id, row.task_id)}>${this._listRef(row.entry_id, row.task_id)}${row.task_name}</span>
|
||||
<span class="cell task-name" @click=${() => this._showTask(row.entry_id, row.task_id)}>${this._listRef(row.entry_id, row.task_id)}${row.task_name}${renderEventTitles(row.next_event_titles)}</span>
|
||||
</span>
|
||||
<span class="task-sub${hasSub ? '' : ' task-sub-empty'}">
|
||||
${row.group_names.length > 0 ? html`
|
||||
@@ -4308,10 +4403,9 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
.then((d) => d?.openCreate(obj.entry_id));
|
||||
}}>${t("add_first_task", L)}</ha-button>
|
||||
</div>`
|
||||
: html`<div class="task-table object-tasks">${[...visibleTasks].sort((a, b) => {
|
||||
const so: Record<string, number> = { overdue: 0, triggered: 1, due_soon: 2, ok: 3 };
|
||||
return (so[a.status] ?? 9) - (so[b.status] ?? 9) || (a.days_until_due ?? 99999) - (b.days_until_due ?? 99999);
|
||||
}).map((task) => html`
|
||||
: html`<div class="task-table object-tasks">${[...visibleTasks].sort((a, b) =>
|
||||
statusRank(a.status) - statusRank(b.status) || (a.days_until_due ?? 99999) - (b.days_until_due ?? 99999),
|
||||
).map((task) => html`
|
||||
<div class="task-row${!task.enabled ? ' task-disabled' : ''}">
|
||||
<span class="cell-badges">
|
||||
${this._statusBadge(!!task.archived, !!task.is_done, task.status)}
|
||||
@@ -4321,7 +4415,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
? html`<span class="doc-badge" title="${task.document_count} ${t("documents", L)}"><ha-icon icon="mdi:paperclip"></ha-icon>${task.document_count}</span>`
|
||||
: nothing}
|
||||
</span>
|
||||
<span class="cell task-name" @click=${() => this._showTask(obj.entry_id, task.id)}>${this._listRef(obj.entry_id, task.id)}${task.name}</span>
|
||||
<span class="cell task-name" @click=${() => this._showTask(obj.entry_id, task.id)}>${this._listRef(obj.entry_id, task.id)}${task.name}${renderEventTitles(task.next_event_titles)}</span>
|
||||
<span class="task-sub${task.responsible_user_id ? '' : ' task-sub-empty'}">${renderUserBadge(task, (id) => this._userService?.getUserName(id) ?? null, (id) => this._userService?.getPerson(id) ?? null)}</span>
|
||||
<span class="cell type">${t(task.type, L)}</span>
|
||||
<span class="due-cell" @click=${() => this._showTask(obj.entry_id, task.id)}>
|
||||
@@ -4626,7 +4720,8 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
currencySymbol: this._currencySymbol,
|
||||
setFilter: (f) => { this._historyFilter = f; },
|
||||
setSearch: (s) => { this._historySearch = s; },
|
||||
openEdit: (entry) => this._openHistoryEdit(entry),
|
||||
// Write tier (task/history/update): no pencil for a read-only member.
|
||||
openEdit: this._isOperator ? undefined : (entry) => this._openHistoryEdit(entry),
|
||||
readingUnit: task?.reading_unit ?? null,
|
||||
taskRef: taskRef(this._selectedEntryId ? this._getObject(this._selectedEntryId)?.object : null, task),
|
||||
// #139: name the phase badge on entries stamped with a phase_id.
|
||||
|
||||
Reference in New Issue
Block a user