217 files
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
import { LitElement, html, css, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { sharedStyles, STATUS_COLORS, t, ensureLocale, isLocaleLoaded, setDateTimePrefs } from "./styles";
|
||||
import { sharedStyles, STATUS_COLORS, t, ensureLocale, isLocaleLoaded, setDateTimePrefs, formatDueDays } from "./styles";
|
||||
import type {
|
||||
HomeAssistant,
|
||||
MaintenanceObjectResponse,
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
SavedViewFilters,
|
||||
} from "./types";
|
||||
import { UserService } from "./user-service";
|
||||
import { partsForCompletion } from "./helpers/shared-parts";
|
||||
import "./maintenance-card-editor";
|
||||
import "./components/complete-dialog";
|
||||
import {
|
||||
@@ -21,6 +22,13 @@ import {
|
||||
openTaskQuickActions,
|
||||
} from "./dialog-mount";
|
||||
|
||||
interface CardDoc {
|
||||
id: string;
|
||||
title: string;
|
||||
kind: string;
|
||||
url?: string | null;
|
||||
}
|
||||
|
||||
interface FlatTask {
|
||||
entry_id: string;
|
||||
object_name: string;
|
||||
@@ -39,6 +47,9 @@ export class MaintenanceSupporterCard extends LitElement {
|
||||
@state() private _userNames: Record<string, string> = {};
|
||||
private _userService: UserService | null = null;
|
||||
private _userNamesLoaded = false;
|
||||
/** entry_id → (task_id → documents) for the row chips. */
|
||||
@state() private _taskDocs: Record<string, Record<string, CardDoc[]>> = {};
|
||||
private _docsLoadedFor = new Set<string>();
|
||||
|
||||
private get _lang(): string {
|
||||
return this.hass?.language || "en";
|
||||
@@ -114,6 +125,15 @@ export class MaintenanceSupporterCard extends LitElement {
|
||||
) {
|
||||
this._loadUserNames();
|
||||
}
|
||||
// Documents: same shape of guard — fetch per object, once, and only for
|
||||
// objects that actually have a document attached to one of their tasks.
|
||||
if (this.hass && this._config.show_documents !== false) {
|
||||
for (const obj of this._objects) {
|
||||
if (this._docsLoadedFor.has(obj.entry_id)) continue;
|
||||
if (!obj.tasks.some((tk) => (tk.document_count ?? 0) > 0)) continue;
|
||||
this._loadDocuments(obj.entry_id);
|
||||
}
|
||||
}
|
||||
if (changedProps.has("hass") && this.hass) {
|
||||
if (!this._dataLoaded) {
|
||||
this._dataLoaded = true;
|
||||
@@ -146,12 +166,6 @@ export class MaintenanceSupporterCard extends LitElement {
|
||||
await this._loadViewFilters();
|
||||
}
|
||||
|
||||
/** Resolve display names for the assignee badge (best-effort).
|
||||
*
|
||||
* `users/list` is a READ-tier command, so the household members this card
|
||||
* is built for can call it without admin rights. A failure (or a task
|
||||
* whose user was deleted) leaves the name unresolved and the badge simply
|
||||
* does not render — never a raw user id. */
|
||||
/** Display name of the task's responsible user, or "" when the badge must
|
||||
* stay hidden (feature off, nobody assigned, or the name not resolved).
|
||||
* With a rotation this is whoever is up next — the pointer the engine
|
||||
@@ -163,6 +177,12 @@ export class MaintenanceSupporterCard extends LitElement {
|
||||
return this._userNames[id] || "";
|
||||
}
|
||||
|
||||
/** Resolve display names for the assignee badge (best-effort).
|
||||
*
|
||||
* `users/list` is a READ-tier command, so the household members this card
|
||||
* is built for can call it without admin rights. A failure (or a task
|
||||
* whose user was deleted) leaves the name unresolved and the badge simply
|
||||
* does not render — never a raw user id. */
|
||||
private async _loadUserNames(): Promise<void> {
|
||||
this._userNamesLoaded = true;
|
||||
if (!this._userService) this._userService = new UserService(this.hass);
|
||||
@@ -175,6 +195,62 @@ export class MaintenanceSupporterCard extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch one object's documents and index them by task.
|
||||
*
|
||||
* `documents/list` is READ tier, like `users/list` — the household members
|
||||
* this card is for may call it. A failure leaves the row without chips
|
||||
* rather than breaking the card. */
|
||||
private async _loadDocuments(entryId: string): Promise<void> {
|
||||
this._docsLoadedFor.add(entryId);
|
||||
try {
|
||||
const res = (await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/documents/list",
|
||||
entry_id: entryId,
|
||||
})) as { documents: Array<CardDoc & { task_ids?: string[] }> };
|
||||
const byTask: Record<string, CardDoc[]> = {};
|
||||
for (const doc of res.documents || []) {
|
||||
for (const taskId of doc.task_ids || []) {
|
||||
(byTask[taskId] ||= []).push({ id: doc.id, title: doc.title, kind: doc.kind, url: doc.url });
|
||||
}
|
||||
}
|
||||
this._taskDocs = { ...this._taskDocs, [entryId]: byTask };
|
||||
} catch {
|
||||
// no chips for this object; the card is unaffected otherwise
|
||||
}
|
||||
}
|
||||
|
||||
/** Chips to render on a row: linked documents plus the task's own manual
|
||||
* link, which is the same "the manual is one tap away" affordance. */
|
||||
private _docsFor(entryId: string, task: MaintenanceTask): CardDoc[] {
|
||||
if (this._config.show_documents === false) return [];
|
||||
const linked = this._taskDocs[entryId]?.[task.id] || [];
|
||||
const out = [...linked];
|
||||
if (task.documentation_url) {
|
||||
out.push({ id: `url:${task.id}`, title: t("documentation_label", this._lang), kind: "weblink", url: task.documentation_url });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Open a chip: a web link directly, a stored file through a signed path
|
||||
* (the same route the panel uses, so it works in the Companion app). */
|
||||
private async _openDoc(doc: CardDoc): Promise<void> {
|
||||
if (doc.kind === "weblink" && doc.url) {
|
||||
window.open(doc.url, "_blank", "noopener");
|
||||
return;
|
||||
}
|
||||
const win = window.open("about:blank", "_blank");
|
||||
try {
|
||||
const signed = await this.hass.connection.sendMessagePromise<{ path: string }>({
|
||||
type: "auth/sign_path",
|
||||
path: `/api/maintenance_supporter/document/${doc.id}`,
|
||||
expires: 300,
|
||||
});
|
||||
if (win) win.location.href = new URL(signed.path, window.location.origin).href;
|
||||
} catch {
|
||||
if (win) win.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve the configured saved view's filters (best-effort). A missing or
|
||||
* deleted view degrades to "no view filter" — same fallback semantics as
|
||||
* the backend's notification routing, never an inexplicably empty card. */
|
||||
@@ -222,6 +298,8 @@ export class MaintenanceSupporterCard extends LitElement {
|
||||
entity_ids,
|
||||
filter_due_min_days,
|
||||
filter_due_max_days,
|
||||
filter_labels,
|
||||
filter_areas,
|
||||
max_items,
|
||||
} = this._config;
|
||||
const entityFilter = entity_ids?.length ? new Set(entity_ids) : null;
|
||||
@@ -238,6 +316,13 @@ export class MaintenanceSupporterCard extends LitElement {
|
||||
|
||||
for (const obj of this._objects) {
|
||||
if (filter_objects?.length && !filter_objects.includes(obj.object.name)) continue;
|
||||
// Areas (C8): object-level, so it selects whole objects like
|
||||
// filter_objects does — "the tasks for this room". An object with no
|
||||
// area_id can never satisfy a non-empty list.
|
||||
if (filter_areas?.length) {
|
||||
const areaId = obj.object.area_id;
|
||||
if (!areaId || !filter_areas.includes(areaId)) continue;
|
||||
}
|
||||
for (const task of obj.tasks) {
|
||||
// Completed one-time tasks ("done") are hidden from the active list.
|
||||
if (task.is_done) continue;
|
||||
@@ -245,6 +330,8 @@ export class MaintenanceSupporterCard extends LitElement {
|
||||
// never shown on the Lovelace card.
|
||||
if (task.archived || obj.object.archived) continue;
|
||||
if (filter_status?.length && !filter_status.includes(task.status)) continue;
|
||||
// Labels: a task passes when it carries at least one configured label.
|
||||
if (filter_labels?.length && !(task.labels || []).some((lb) => filter_labels.includes(lb))) continue;
|
||||
// entity_ids: HA-native filter — match the task's sensor or
|
||||
// binary_sensor entity_id. Both fields come pre-resolved from the
|
||||
// backend WS response (see _build_task_summary in websocket/__init__.py).
|
||||
@@ -384,13 +471,26 @@ export class MaintenanceSupporterCard extends LitElement {
|
||||
</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
${this._docsFor(entry_id, task).length
|
||||
? html`<div class="doc-chips">
|
||||
${this._docsFor(entry_id, task).map((doc) => html`
|
||||
<button
|
||||
type="button"
|
||||
class="doc-chip"
|
||||
title="${doc.title}"
|
||||
@click=${(e: Event) => { e.stopPropagation(); void this._openDoc(doc); }}
|
||||
>
|
||||
<ha-icon icon=${doc.kind === "weblink" ? "mdi:link-variant" : "mdi:file-document-outline"}></ha-icon>
|
||||
<span>${doc.title}</span>
|
||||
</button>
|
||||
`)}
|
||||
</div>`
|
||||
: nothing}
|
||||
<div class="task-due">
|
||||
${task.days_until_due !== null && task.days_until_due !== undefined
|
||||
? task.days_until_due < 0
|
||||
? html`<span class="overdue-text">${Math.abs(task.days_until_due)}${L.startsWith("de") ? "T" : "d"}</span>`
|
||||
: task.days_until_due === 0
|
||||
? t("today", L)
|
||||
: `${task.days_until_due}${L.startsWith("de") ? "T" : "d"}`
|
||||
? html`<span class="overdue-text">${formatDueDays(task.days_until_due, L)}</span>`
|
||||
: formatDueDays(task.days_until_due, L)
|
||||
: task.trigger_active
|
||||
? "⚡"
|
||||
: "—"}
|
||||
@@ -411,13 +511,17 @@ export class MaintenanceSupporterCard extends LitElement {
|
||||
dlg.adaptiveEnabled = !!task.adaptive_config?.enabled;
|
||||
dlg.taskType = task.type || "";
|
||||
dlg.readingUnit = (task as any).reading_unit || "";
|
||||
dlg.requiredFields = task.required_completion_fields || [];
|
||||
dlg.lang = L;
|
||||
// #99: editable per-completion parts selection
|
||||
// (skip on buy tasks — those restock instead).
|
||||
const obj = this._objects.find((o) => o.entry_id === entry_id);
|
||||
// #111: the list also carries the shared pools
|
||||
// this task draws on, each named after its
|
||||
// owner — resolving against the object's own
|
||||
// parts alone left a foreign link invisible.
|
||||
const isBuy = !!(task as any).part_ref;
|
||||
dlg.parts = isBuy ? [] : (obj?.parts || []);
|
||||
dlg.consumesParts = isBuy ? [] : ((task as any).consumes_parts || []);
|
||||
dlg.parts = isBuy ? [] : partsForCompletion(task, entry_id, this._objects, L);
|
||||
dlg.consumesParts = isBuy ? [] : (task.consumes_parts || []);
|
||||
dlg.open();
|
||||
}}
|
||||
>
|
||||
@@ -534,7 +638,37 @@ export class MaintenanceSupporterCard extends LitElement {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.task-due { font-size: 13px; color: var(--secondary-text-color); min-width: 40px; text-align: right; }
|
||||
.doc-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-right: 6px;
|
||||
max-width: 45%;
|
||||
}
|
||||
.doc-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
max-width: 14ch;
|
||||
padding: 1px 6px;
|
||||
border: 1px solid var(--divider-color, #e0e0e0);
|
||||
border-radius: 10px;
|
||||
background: none;
|
||||
color: var(--secondary-text-color);
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.doc-chip:hover { color: var(--primary-color); border-color: var(--primary-color); }
|
||||
.doc-chip ha-icon { --mdc-icon-size: 12px; width: 12px; height: 12px; }
|
||||
/* nowrap: the due label is localized via formatDueDays ("5 d overdue",
|
||||
"5 T überfällig") — without it a narrow phone card wraps that onto a
|
||||
second line and the row grows taller. The name column ellipsizes
|
||||
instead, which it already does by design. */
|
||||
.task-due { font-size: 13px; color: var(--secondary-text-color); min-width: 40px; text-align: right; white-space: nowrap; }
|
||||
.overdue-text { color: var(--error-color); font-weight: 500; }
|
||||
|
||||
.complete-btn {
|
||||
|
||||
Reference in New Issue
Block a user