Files
2026-07-20 22:52:35 -04:00

487 lines
16 KiB
TypeScript

/**
* 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 <input>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<T>(msg: Record<string, unknown>): Promise<T | null> {
this._busy = true;
this._error = "";
try {
return await this.hass.connection.sendMessagePromise<T>(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<string, unknown> {
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<void> {
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<void> {
// 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<void> {
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`
<div class="part-row ${part.is_low ? "low" : ""}">
<ha-icon class="part-icon" icon=${part.is_low ? "mdi:cart-arrow-down" : "mdi:package-variant-closed"}></ha-icon>
<div class="part-main">
<div class="part-name">
${isSafeHttpUrl(part.shopping_url)
? html`<a href=${part.shopping_url} target="_blank" rel="noopener noreferrer">${part.name}</a>`
: part.name}
${tracked
? html`<span class="stock-badge ${part.is_low ? "low" : ""}"
>${part.stock}${part.unit ? ` ${part.unit}` : ""}${part.reorder_threshold != null
? html`<span class="threshold">/${part.reorder_threshold}</span>`
: nothing}</span
>`
: nothing}
</div>
<div class="part-meta">
${ident ? html`<span>${ident}</span>` : nothing}
${part.storage_location
? html`<span class="loc"><ha-icon icon="mdi:map-marker-outline"></ha-icon>${part.storage_location}</span>`
: nothing}
</div>
</div>
<ha-icon-button
title=${t("documents", L)}
class=${docsOpen ? "docs-open" : ""}
@click=${() => (this._docsFor = docsOpen ? null : part.id)}
><ha-icon icon="mdi:paperclip"></ha-icon
></ha-icon-button>
${this.canWrite
? html`
${this._restockFor === part.id
? html`
<input
class="restock-input${this._restockInvalid ? " invalid" : ""}"
type="number"
.value=${this._restockQty}
placeholder="+1"
@input=${(e: Event) => (this._restockQty = (e.target as HTMLInputElement).value)}
@keydown=${(e: KeyboardEvent) => {
if (e.key === "Enter") this._restock(part);
if (e.key === "Escape") this._restockFor = null;
}}
/>
<ha-icon-button title=${t("save", L)} @click=${() => this._restock(part)}
><ha-icon icon="mdi:check"></ha-icon
></ha-icon-button>
`
: html`
<ha-icon-button
title=${t("part_restock", L)}
.disabled=${this._busy}
@click=${() => {
this._restockFor = part.id;
this._restockInvalid = false;
this._restockQty = String(part.restock_quantity || 1);
}}
><ha-icon icon="mdi:plus-minus-variant"></ha-icon
></ha-icon-button>
`}
<ha-icon-button title=${t("edit", L)} .disabled=${this._busy} @click=${() => this._openEdit(part)}
><ha-icon icon="mdi:pencil"></ha-icon
></ha-icon-button>
<ha-icon-button title=${t("delete", L)} .disabled=${this._busy} @click=${() => this._delete(part)}
><ha-icon icon="mdi:delete-outline"></ha-icon
></ha-icon-button>
`
: nothing}
</div>
${docsOpen
? html`<div class="part-docs">
<maintenance-task-documents
.hass=${this.hass}
.entryId=${this.entryId}
.partId=${part.id}
.canWrite=${this.canWrite}
></maintenance-task-documents>
</div>`
: nothing}
`;
}
private _field(label: string, key: keyof PartForm, opts: { type?: string; placeholder?: string } = {}) {
const f = this._editing!;
return html`
<label class="form-field">
<span>${label}</span>
<input
type=${opts.type || "text"}
.value=${String(f[key] ?? "")}
placeholder=${opts.placeholder || ""}
@input=${(e: Event) => {
(this._editing as unknown as Record<string, unknown>)[key] = (e.target as HTMLInputElement).value;
this.requestUpdate();
}}
/>
</label>
`;
}
private _renderForm() {
const L = this._lang;
const f = this._editing!;
return html`
<div class="part-form">
<div class="form-grid">
${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" })}
<label class="form-field checkbox">
<input
type="checkbox"
.checked=${f.auto_buy_task}
@change=${(e: Event) => {
this._editing = { ...f, auto_buy_task: (e.target as HTMLInputElement).checked };
}}
/>
<span>${t("part_auto_buy", L)}</span>
</label>
</div>
<div class="form-actions">
<ha-button appearance="plain" @click=${() => (this._editing = null)}>${t("cancel", L)}</ha-button>
<ha-button .disabled=${this._busy || !f.name.trim()} @click=${() => this._save()}
>${t("save", L)}</ha-button
>
</div>
</div>
`;
}
protected render() {
const L = this._lang;
if (!this.parts.length && !this.canWrite) return nothing;
return html`
<div class="section-head">
<h3>
<ha-icon icon="mdi:package-variant"></ha-icon>
${t("parts_section", L)} (${this.parts.length})
</h3>
${this.canWrite && !this._editing
? html`<ha-button appearance="plain" @click=${() => this._openAdd()}>
<ha-icon icon="mdi:plus"></ha-icon> ${t("part_add", L)}
</ha-button>`
: nothing}
</div>
${this._error ? html`<div class="error">${this._error}</div>` : 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);