/** * Spare parts & consumables section on the object detail page. * * Shows the object's parts list (stock badge, storage location, identifiers, * shopping link), lets admins add/edit/delete parts, and adjust stock * (inventory correction / manual restock). Follows the documents-section * conventions: standalone LitElement fed by the panel, native s in the * inline form (the ha-textfield-in-custom-panel trap), t() i18n. * * After a create/delete (entity set changes → backend reloads the entry) it * fires "parts-changed" so the panel re-fetches objects; stock adjustments * update the local copy from the WS response for instant feedback. */ import { LitElement, html, css, nothing } from "lit"; import { isSafeHttpUrl } from "../helpers/url"; import { property, state } from "lit/decorators.js"; import { t, ensureLocale } from "../styles"; import { describeWsError } from "../ws-errors"; import type { HomeAssistant, MaintenancePart } from "../types"; // Per-part document links (v2.26) — the task-documents component in part mode. import "./task-documents"; interface PartForm { id?: string; name: string; vendor: string; mpn: string; gtin: string; storage_location: string; product_url: string; unit: string; cost: string; stock: string; reorder_threshold: string; restock_quantity: string; auto_buy_task: boolean; notes: string; } const EMPTY_FORM: PartForm = { name: "", vendor: "", mpn: "", gtin: "", storage_location: "", product_url: "", unit: "", cost: "", stock: "", reorder_threshold: "", restock_quantity: "", auto_buy_task: true, notes: "", }; export class MaintenancePartsSection extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public entryId!: string; @property({ attribute: false }) public parts: MaintenancePart[] = []; @property({ type: Boolean }) public canWrite = false; @state() private _editing: PartForm | null = null; @state() private _busy = false; @state() private _error = ""; @state() private _restockFor: string | null = null; @state() private _restockQty = ""; @state() private _restockInvalid = false; @state() private _docsFor: string | null = null; private get _lang(): string { return this.hass?.locale?.language || this.hass?.language || "en"; } public connectedCallback(): void { super.connectedCallback(); // Lazy-load the locale bundle for t() (same pattern as documents-section). void ensureLocale(this._lang).then(() => this.requestUpdate()); } private _notifyChanged(): void { this.dispatchEvent(new CustomEvent("parts-changed", { bubbles: true, composed: true })); } private async _send(msg: Record): Promise { this._busy = true; this._error = ""; try { return await this.hass.connection.sendMessagePromise(msg); } catch (err) { this._error = describeWsError(err, this._lang); return null; } finally { this._busy = false; } } private _openAdd(): void { this._editing = { ...EMPTY_FORM }; } private _openEdit(part: MaintenancePart): void { this._editing = { id: part.id, name: part.name, vendor: part.vendor || "", mpn: part.mpn || "", gtin: part.gtin || "", storage_location: part.storage_location || "", product_url: part.product_url || "", unit: part.unit || "", cost: part.cost != null ? String(part.cost) : "", stock: part.stock != null ? String(part.stock) : "", reorder_threshold: part.reorder_threshold != null ? String(part.reorder_threshold) : "", restock_quantity: part.restock_quantity != null ? String(part.restock_quantity) : "", auto_buy_task: !!part.auto_buy_task, notes: part.notes || "", }; } private _formValue(f: PartForm): Record { const num = (s: string): number | null => (s.trim() === "" ? null : Number(s)); return { entry_id: this.entryId, name: f.name.trim(), vendor: f.vendor.trim() || null, mpn: f.mpn.trim() || null, gtin: f.gtin.trim() || null, storage_location: f.storage_location.trim() || null, product_url: f.product_url.trim() || null, unit: f.unit.trim() || null, cost: num(f.cost), stock: num(f.stock), reorder_threshold: num(f.reorder_threshold), restock_quantity: num(f.restock_quantity), auto_buy_task: f.auto_buy_task, notes: f.notes.trim() || null, }; } private async _save(): Promise { const f = this._editing; if (!f || !f.name.trim()) return; const payload = this._formValue(f); const type = f.id ? "maintenance_supporter/part/update" : "maintenance_supporter/part/create"; const result = await this._send<{ part_id?: string }>(f.id ? { type, part_id: f.id, ...payload } : { type, ...payload }); if (result !== null) { this._editing = null; this._notifyChanged(); } } private async _delete(part: MaintenancePart): Promise { // Deleting a part drops its stock tracking, task links and buy reminder — // destructive enough to warrant an explicit confirmation (user request). if (!window.confirm(t("part_delete_confirm", this._lang).replace("{name}", part.name))) return; const result = await this._send<{ success: boolean }>({ type: "maintenance_supporter/part/delete", entry_id: this.entryId, part_id: part.id, }); if (result !== null) this._notifyChanged(); } private async _restock(part: MaintenancePart): Promise { const qty = parseFloat(this._restockQty); if (!Number.isFinite(qty) || qty === 0) { // Don't silently swallow a no-op amount — keep the input open and mark // it so the user sees WHY nothing happened (0 / empty / not a number). this._restockInvalid = true; return; } this._restockInvalid = false; const result = await this._send<{ stock: number }>({ type: "maintenance_supporter/part/restock", entry_id: this.entryId, part_id: part.id, delta: qty, }); this._restockFor = null; if (result !== null) { // Instant local feedback; the panel refresh follows via parts-changed. part.stock = result.stock; this.requestUpdate(); this._notifyChanged(); } } private _identLine(part: MaintenancePart): string { return [part.vendor, part.mpn ? `MPN: ${part.mpn}` : "", part.gtin ? `GTIN: ${part.gtin}` : ""] .filter(Boolean) .join(" · "); } private _renderRow(part: MaintenancePart) { const L = this._lang; const tracked = part.stock !== null && part.stock !== undefined; const ident = this._identLine(part); const docsOpen = this._docsFor === part.id; return html`
${isSafeHttpUrl(part.shopping_url) ? html`${part.name}` : part.name} ${tracked ? html`${part.stock}${part.unit ? ` ${part.unit}` : ""}${part.reorder_threshold != null ? html`/${part.reorder_threshold}` : nothing}` : nothing}
${ident ? html`${ident}` : nothing} ${part.storage_location ? html`${part.storage_location}` : nothing}
(this._docsFor = docsOpen ? null : part.id)} > ${this.canWrite ? html` ${this._restockFor === part.id ? html` (this._restockQty = (e.target as HTMLInputElement).value)} @keydown=${(e: KeyboardEvent) => { if (e.key === "Enter") this._restock(part); if (e.key === "Escape") this._restockFor = null; }} /> this._restock(part)} > ` : html` { this._restockFor = part.id; this._restockInvalid = false; this._restockQty = String(part.restock_quantity || 1); }} > `} this._openEdit(part)} > this._delete(part)} > ` : nothing}
${docsOpen ? html`
` : nothing} `; } private _field(label: string, key: keyof PartForm, opts: { type?: string; placeholder?: string } = {}) { const f = this._editing!; return html` `; } private _renderForm() { const L = this._lang; const f = this._editing!; return html`
${this._field(t("part_name", L), "name")} ${this._field(t("part_vendor", L), "vendor")} ${this._field("MPN", "mpn")} ${this._field("GTIN / EAN", "gtin", { placeholder: "4006381333931" })} ${this._field(t("part_storage_location", L), "storage_location")} ${this._field(t("part_product_url", L), "product_url", { placeholder: "https://…" })} ${this._field(t("part_unit", L), "unit")} ${this._field(t("part_cost", L), "cost", { type: "number" })} ${this._field(t("part_stock", L), "stock", { type: "number" })} ${this._field(t("part_reorder_threshold", L), "reorder_threshold", { type: "number" })} ${this._field(t("part_restock_quantity", L), "restock_quantity", { type: "number" })}
(this._editing = null)}>${t("cancel", L)} this._save()} >${t("save", L)}
`; } protected render() { const L = this._lang; if (!this.parts.length && !this.canWrite) return nothing; return html`

${t("parts_section", L)} (${this.parts.length})

${this.canWrite && !this._editing ? html` this._openAdd()}> ${t("part_add", L)} ` : nothing}
${this._error ? html`
${this._error}
` : nothing} ${this._editing ? this._renderForm() : nothing} ${this.parts.map((part) => this._renderRow(part))} `; } static styles = css` :host { display: block; margin: 12px 0; } .section-head { display: flex; align-items: center; justify-content: space-between; } h3 { display: flex; align-items: center; gap: 6px; margin: 8px 0; } .part-row { display: flex; align-items: center; gap: 8px; padding: 6px 4px; border-bottom: 1px solid var(--divider-color); } .part-row.low .part-icon { color: var(--warning-color, #ff9800); } .part-main { flex: 1; min-width: 0; } .part-name { font-weight: 500; display: flex; align-items: center; gap: 8px; } .part-name a { color: var(--primary-color); text-decoration: none; } .stock-badge { font-size: 12px; padding: 1px 8px; border-radius: 10px; background: var(--secondary-background-color); } .stock-badge.low { background: var(--warning-color, #ff9800); color: var(--text-primary-color, #fff); } .stock-badge .threshold { opacity: 0.7; } .part-meta { font-size: 12px; color: var(--secondary-text-color); display: flex; gap: 12px; flex-wrap: wrap; } .part-meta .loc ha-icon { --mdc-icon-size: 13px; } .restock-input { width: 64px; padding: 4px; border: 1px solid var(--divider-color); border-radius: 4px; background: var(--card-background-color); color: var(--primary-text-color); } .restock-input.invalid { border-color: var(--error-color, #f44336); } .docs-open { color: var(--primary-color); } .part-docs { padding: 0 4px 8px 34px; border-bottom: 1px solid var(--divider-color); } .part-form { border: 1px solid var(--divider-color); border-radius: 8px; padding: 12px; margin-bottom: 8px; } .form-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 8px 12px; } .form-field { display: flex; flex-direction: column; gap: 2px; font-size: 12px; color: var(--secondary-text-color); } .form-field input[type="text"], .form-field input[type="number"] { padding: 6px; border: 1px solid var(--divider-color); border-radius: 4px; background: var(--card-background-color); color: var(--primary-text-color); } .form-field.checkbox { flex-direction: row; align-items: center; gap: 6px; align-self: end; } .form-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 10px; } .error { color: var(--error-color); font-size: 13px; margin: 4px 0; } `; } customElements.define("maintenance-parts-section", MaintenancePartsSection);