/** Documents linked to a maintenance task — or (v2.26) to a spare part. * * A filtered view over the object's document pool via each doc's `task_ids` * (task mode) or `part_ids` (part mode, set `partId` instead of `taskId`): * link/unlink existing object documents and open/download them. Full upload + * management lives at the object level (documents-section); this keeps the * manual / datasheet right where the work happens. Per-task PDF page hints * exist only in task mode. Hides itself entirely when the object has no * documents at all. */ import { LitElement, html, css, nothing } from "lit"; import { property, state } from "lit/decorators.js"; import { t, ensureLocale } from "../styles"; import { describeWsError } from "../ws-errors"; import { downloadUrl } from "../helpers/download"; import { formatBytes } from "../helpers/format-bytes"; import type { HomeAssistant } from "../types"; interface Doc { id: string; kind: "file" | "weblink"; title: string; filename?: string; url?: string; mime?: string; size?: number; tags?: string[]; task_ids?: string[]; task_pages?: Record; part_ids?: string[]; } const CATEGORIES = ["manual", "warranty", "invoice", "spare_parts", "photo", "other"] as const; const CATEGORY_ICONS: Record = { manual: "mdi:book-open-variant", warranty: "mdi:shield-check", invoice: "mdi:receipt-text-outline", spare_parts: "mdi:cog-outline", photo: "mdi:image-outline", other: "mdi:file-document-outline", }; export class MaintenanceTaskDocuments extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public entryId!: string; @property({ attribute: false }) public taskId?: string; /** Part mode: link docs to this spare part (via `part_ids`) instead of a task. */ @property({ attribute: false }) public partId?: string; @property({ type: Boolean }) public canWrite = false; @state() private _docs: Doc[] = []; @state() private _loaded = false; @state() private _busy = false; @state() private _error = ""; @state() private _attachId = ""; private _loadedKey = ""; private _localeReady = false; private get _lang(): string { return this.hass?.language || "en"; } /** The id linked docs reference — a task id or (part mode) a part id. */ private get _refId(): string { return this.partId || this.taskId || ""; } /** The doc metadata field the link lives in. */ private get _linkField(): "task_ids" | "part_ids" { return this.partId ? "part_ids" : "task_ids"; } updated(changed: Map): void { super.updated(changed); if (this.hass && !this._localeReady) { this._localeReady = true; void ensureLocale(this._lang).then(() => this.requestUpdate()); } const key = `${this.entryId}|${this._refId}`; if (this.hass && this.entryId && this._refId && this._loadedKey !== key) { this._loadedKey = key; void this._load(); } } private async _load(): Promise { try { const r = await this.hass.connection.sendMessagePromise<{ documents: Doc[] }>({ type: "maintenance_supporter/documents/list", entry_id: this.entryId, }); this._docs = r.documents || []; this._loaded = true; this._error = ""; } catch (e) { this._error = describeWsError(e, this._lang); this._loaded = true; } } private _links(doc: Doc): string[] { return doc[this._linkField] || []; } private _linked(): Doc[] { return this._docs.filter((d) => this._links(d).includes(this._refId)); } private _available(): Doc[] { return this._docs.filter((d) => !this._links(d).includes(this._refId)); } private async _setLinks(doc: Doc, ids: string[]): Promise { this._busy = true; this._error = ""; try { await this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/documents/update", doc_id: doc.id, [this._linkField]: ids, }); await this._load(); } catch (e) { this._error = describeWsError(e, this._lang); } finally { this._busy = false; } } private _link(): void { const doc = this._docs.find((d) => d.id === this._attachId); if (!doc) return; this._attachId = ""; void this._setLinks(doc, [...this._links(doc), this._refId]); } private _unlink(doc: Doc): void { void this._setLinks(doc, this._links(doc).filter((x) => x !== this._refId)); } private _isPdf(doc: Doc): boolean { return doc.mime === "application/pdf" || (doc.filename || "").toLowerCase().endsWith(".pdf"); } /** The page this doc should open at for the current task, if set (PDFs only; * page hints are a task-mode concept — none in part mode). */ private _pageFor(doc: Doc): number | undefined { return this._isPdf(doc) && this.taskId ? doc.task_pages?.[this.taskId] : undefined; } private async _open(doc: Doc): Promise { if (doc.kind === "weblink") { window.open(doc.url, "_blank", "noopener"); return; } // A per-task page hint jumps straight to the relevant page via the PDF // viewer's #page=N fragment (client-side, so it never breaks the signature). const page = this._pageFor(doc); const frag = page ? `#page=${page}` : ""; const win = window.open("about:blank", "_blank"); try { const s = await this.hass.connection.sendMessagePromise<{ path: string }>({ type: "auth/sign_path", path: `/api/maintenance_supporter/document/${doc.id}`, expires: 300, }); // Absolute URL: a fragment on a *root-relative* path won't resolve against // the blank popup's about:blank base, so it would silently stay blank. if (win) win.location.href = new URL(s.path + frag, window.location.origin).href; } catch (e) { if (win) win.close(); this._error = describeWsError(e, this._lang); } } /** Set (page >= 1) or clear (0) the jump-to page for this doc's task link. */ private async _setPage(doc: Doc, page: number): Promise { if (!this.taskId) return; this._busy = true; this._error = ""; try { await this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/documents/update", doc_id: doc.id, task_pages: { [this.taskId]: page }, }); await this._load(); } catch (e) { this._error = describeWsError(e, this._lang); } finally { this._busy = false; } } private async _download(doc: Doc): Promise { try { const s = await this.hass.connection.sendMessagePromise<{ path: string }>({ type: "auth/sign_path", path: `/api/maintenance_supporter/document/${doc.id}`, expires: 30, }); downloadUrl(s.path, doc.filename || doc.title || "document"); } catch (e) { this._error = describeWsError(e, this._lang); } } render() { // Hide entirely for objects with no documents — no clutter on every task. if (!this._loaded || this._docs.length === 0) return nothing; const L = this._lang; const linked = this._linked(); const available = this._available(); return html`

${t("documents", L)} (${linked.length})

${this._error ? html`
${this._error}
` : nothing} ${linked.length === 0 ? html`
${t(this.partId ? "doc_part_none" : "doc_task_none", L)}
` : html`
${linked.map((d) => this._renderRow(d, L))}
`} ${this.canWrite && available.length ? html`
` : nothing}
`; } private _renderRow(doc: Doc, L: string) { const isFile = doc.kind === "file"; const isPdf = this._isPdf(doc); const page = this._pageFor(doc); const cat = (doc.tags || []).find((x) => (CATEGORIES as readonly string[]).includes(x)) || "other"; const meta = isFile ? formatBytes(doc.size) : t("doc_link_badge", L); return html`
this._open(doc)} @keydown=${(e: KeyboardEvent) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); void this._open(doc); } }} >
${doc.title || doc.filename || doc.url}
${meta}${page ? html` · ${t("doc_page", L)} ${page}` : nothing}
${this.canWrite && isPdf && this.taskId ? html` { const v = parseInt((e.target as HTMLInputElement).value, 10); void this._setPage(doc, Number.isFinite(v) && v >= 1 ? v : 0); }} />` : nothing} ${isFile ? html`` : nothing} ${this.canWrite ? html`` : nothing}
`; } static styles = css` :host { display: block; } .task-docs { margin-top: 20px; } h3 { display: flex; align-items: center; gap: 6px; margin: 0 0 8px; font-size: 15px; color: var(--primary-text-color); } h3 ha-icon { --mdc-icon-size: 18px; color: var(--secondary-text-color, #888); } .tdoc-empty { color: var(--secondary-text-color, #888); font-size: 13px; padding: 2px 0 8px; } .tdoc-error { color: var(--error-color, #f44336); font-size: 13px; margin: 4px 0; } .tdoc-list { display: flex; flex-direction: column; gap: 4px; } .tdoc-row { display: flex; align-items: center; gap: 10px; padding: 6px 10px; border: 1px solid var(--divider-color); border-radius: 8px; } .tdoc-icon { color: var(--primary-color); --mdc-icon-size: 22px; flex: none; } .tdoc-info { flex: 1; min-width: 0; cursor: pointer; border-radius: 6px; } .tdoc-info:hover .tdoc-title { text-decoration: underline; } .tdoc-info:focus-visible { outline: 2px solid var(--primary-color); outline-offset: 2px; } .tdoc-title { font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .tdoc-meta { font-size: 12px; color: var(--secondary-text-color, #888); } .tdoc-pagetag { color: var(--primary-color); font-weight: 500; } .tdoc-page { flex: none; width: 76px; padding: 5px 8px; border-radius: 6px; font: inherit; font-size: 13px; background: var(--secondary-background-color, rgba(0, 0, 0, 0.06)); color: var(--primary-text-color); border: 1px solid var(--divider-color); } .tdoc-page:disabled { opacity: 0.5; } .icon-btn { display: inline-flex; align-items: center; justify-content: center; width: 34px; height: 34px; border-radius: 8px; cursor: pointer; background: transparent; border: none; color: var(--primary-text-color); } .icon-btn:hover { background: var(--secondary-background-color, rgba(0, 0, 0, 0.06)); } .icon-btn[disabled] { opacity: 0.4; pointer-events: none; } .icon-btn ha-icon { --mdc-icon-size: 20px; } .tdoc-attach { display: flex; gap: 8px; margin-top: 8px; flex-wrap: wrap; } .tdoc-select { flex: 1; min-width: 160px; padding: 6px 10px; border-radius: 6px; font: inherit; background: var(--secondary-background-color, rgba(0, 0, 0, 0.06)); color: var(--primary-text-color); border: 1px solid var(--divider-color); } .tdoc-btn { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; padding: 6px 12px; border-radius: 6px; font: inherit; font-size: 13px; background: var(--secondary-background-color, rgba(0, 0, 0, 0.06)); color: var(--primary-text-color); border: 1px solid var(--divider-color); } .tdoc-btn ha-icon { --mdc-icon-size: 18px; } .tdoc-btn[disabled] { opacity: 0.5; pointer-events: none; } `; } if (!customElements.get("maintenance-task-documents")) { customElements.define("maintenance-task-documents", MaintenanceTaskDocuments); }