Files
HomeAssistantVS/custom_components/maintenance_supporter/frontend-src/components/parts-section.ts
T

513 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
@property({ attribute: false }) public currencySymbol = "€";
@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>
`;
}
/** Inventory value = Σ unit cost × tracked stock (#104). Parts without a
* price or without tracked stock contribute nothing; null = no part has
* both, so the chip stays hidden rather than showing a misleading 0. */
private _inventoryValue(): number | null {
let sum = 0, any = false;
for (const p of this.parts) {
const cost = typeof p.cost === "number" ? p.cost : null;
const stock = typeof p.stock === "number" ? p.stock : null;
if (cost !== null && stock !== null) { sum += cost * stock; any = true; }
}
return any ? sum : null;
}
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})
${this._inventoryValue() !== null
? html`<span class="inventory-value" title=${t("parts_inventory_value", L)}
>${t("parts_inventory_value", L)}:
${this._inventoryValue()!.toFixed(2)}&nbsp;${this.currencySymbol}</span>`
: nothing}
</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;
}
.inventory-value {
margin-left: 8px;
font-size: 0.75em;
font-weight: 400;
color: var(--secondary-text-color);
white-space: nowrap;
}
.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);