Files
HomeAssistantVS/custom_components/maintenance_supporter/frontend-src/components/complete-dialog.ts
T
2026-07-20 22:52:35 -04:00

469 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.
/** Dialog for completing a maintenance task with optional notes, cost, duration. */
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import type { HomeAssistant } from "../types";
import { t, nativeFieldStyles } from "../styles";
import { describeWsError } from "../ws-errors";
export class MaintenanceCompleteDialog extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public entryId = "";
@property() public taskId = "";
@property() public taskName = "";
@property() public lang = "en";
@property({ type: Array }) public checklist: string[] = [];
@property({ type: Boolean }) public adaptiveEnabled = false;
// v2.20 (#83): task type + unit drive the reading-value field below.
@property() public taskType = "";
@property() public readingUnit = "";
/** Buy task (part_ref): default restock quantity — shows an editable qty field. */
@property({ attribute: false }) public restockDefault: number | null = null;
/** #99: the object's parts — enables the editable "parts used" section. */
@property({ attribute: false }) public parts: Array<{ id: string; name: string; unit?: string | null; stock?: number | null }> = [];
/** #99: the task's fixed consumes_parts links (prefill for the section). */
@property({ attribute: false }) public consumesParts: Array<{ part_id: string; quantity: number }> = [];
/** "Consumes: 1× HEPA-Filter (Shelf B)" hint lines for consuming tasks. */
@property({ type: Array }) public consumesInfo: string[] = [];
@state() private _open = false;
@state() private _notes = "";
@state() private _cost = "";
@state() private _duration = "";
@state() private _loading = false;
@state() private _error = "";
@state() private _checklistState: Record<string, boolean> = {};
@state() private _feedback: string = "needed";
@state() private _photoDocId = "";
@state() private _photoPreview = "";
@state() private _photoUploading = false;
@state() private _readingValue = "";
@state() private _restockQty = "";
@state() private _usedParts: Record<string, number> = {};
public open(): void {
if (this._open) return;
this._open = true;
this._notes = "";
this._cost = "";
this._duration = "";
this._error = "";
this._checklistState = {};
this._feedback = "needed";
this._photoDocId = "";
this._photoPreview = "";
this._photoUploading = false;
this._readingValue = "";
this._restockQty = this.restockDefault !== null ? String(this.restockDefault) : "";
// #99: prefill "parts used" with the task's fixed links — the user can
// untick or adjust before completing.
this._usedParts = Object.fromEntries(this.consumesParts.map((l) => [l.part_id, l.quantity]));
}
private _toggleCheck(idx: number): void {
const key = String(idx);
this._checklistState = {
...this._checklistState,
[key]: !this._checklistState[key],
};
}
private _setFeedback(value: string): void {
this._feedback = value;
}
private async _onPhotoInput(e: Event): Promise<void> {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
input.value = ""; // allow re-picking the same file
if (!file) return;
this._photoUploading = true;
this._error = "";
try {
const form = new FormData();
form.append("entry_id", this.entryId);
form.append("tags", "photo");
form.append("file", file, file.name);
const resp = await fetch("/api/maintenance_supporter/document/upload", {
method: "POST",
headers: { Authorization: `Bearer ${this.hass.auth?.data?.access_token ?? ""}` },
body: form,
});
if (!resp.ok) {
this._error = resp.status === 413
? t("doc_too_large", this.lang)
: t("doc_upload_failed", this.lang);
return;
}
const doc = (await resp.json()) as { id?: string };
if (doc.id) {
this._photoDocId = doc.id;
this._photoPreview = URL.createObjectURL(file);
}
} catch {
this._error = t("doc_upload_failed", this.lang);
} finally {
this._photoUploading = false;
}
}
private _removePhoto(): void {
if (this._photoPreview) URL.revokeObjectURL(this._photoPreview);
this._photoDocId = "";
this._photoPreview = "";
}
private async _complete(): Promise<void> {
this._loading = true;
this._error = "";
try {
const data: Record<string, unknown> = {
type: "maintenance_supporter/task/complete",
entry_id: this.entryId,
task_id: this.taskId,
};
if (this._notes) data.notes = this._notes;
if (this._cost) {
const cost = parseFloat(this._cost);
if (!isNaN(cost) && cost >= 0) data.cost = cost;
}
if (this._duration) {
const dur = parseInt(this._duration, 10);
if (!isNaN(dur) && dur >= 0) data.duration = dur;
}
if (this.checklist.length > 0) {
data.checklist_state = this._checklistState;
}
if (this.adaptiveEnabled) {
data.feedback = this._feedback;
}
if (this._photoDocId) {
data.photo_doc_id = this._photoDocId;
}
if (this._readingValue !== "") {
const rv = parseFloat(this._readingValue);
if (!isNaN(rv)) data.reading_value = rv;
}
if (this.restockDefault !== null && this._restockQty !== "") {
const rq = parseFloat(this._restockQty);
if (!isNaN(rq) && rq >= 1) data.restock_quantity = rq;
}
// #99: with a parts section shown, send the explicit selection — it
// replaces the automatic consumes_parts deduction (empty = none used).
if (this.parts.length > 0) {
data.used_parts = Object.entries(this._usedParts)
.filter(([, qty]) => Number.isFinite(qty) && qty > 0)
.map(([part_id, quantity]) => ({ part_id, quantity }));
}
await this.hass.connection.sendMessagePromise(data);
this._open = false;
this.dispatchEvent(new CustomEvent("task-completed"));
} catch (e) {
this._error = describeWsError(e, this.lang, t("save_error", this.lang));
} finally {
this._loading = false;
}
}
private _close(): void {
this._open = false;
}
render() {
if (!this._open) return html``;
const L = this.lang || this.hass?.language || "en";
return html`
<ha-dialog open @closed=${this._close}>
<div class="dialog-title">${t("complete_title", L)}${this.taskName}</div>
<div class="content">
${this._error ? html`<div class="error">${this._error}</div>` : nothing}
${this.checklist.length > 0 ? html`
<div class="checklist-section">
<label class="checklist-label">${t("checklist", L)}</label>
${this.checklist.map((item, idx) => html`
<label class="checklist-item" @click=${() => this._toggleCheck(idx)}>
<input type="checkbox" .checked=${!!this._checklistState[String(idx)]} />
<span>${item}</span>
</label>
`)}
</div>
` : nothing}
${this.taskType === "reading"
? html`
<label class="field">
<span class="field-label">${t("reading_value_label", L)}${this.readingUnit ? ` (${this.readingUnit})` : ""}</span>
<input type="number" step="any" class="field-input"
.value=${this._readingValue}
@input=${(e: Event) => (this._readingValue = (e.target as HTMLInputElement).value)} />
</label>`
: nothing}
${this.parts.length
? html`<div class="used-parts">
<span class="field-label">${t("complete_parts_used", L)}</span>
${this.parts.map((pt) => {
const qty = this._usedParts[pt.id];
const checked = qty !== undefined;
return html`<div class="used-part-row">
<label class="used-part-check">
<input type="checkbox" .checked=${checked}
@change=${(e: Event) => {
const next = { ...this._usedParts };
if ((e.target as HTMLInputElement).checked) next[pt.id] = next[pt.id] || 1;
else delete next[pt.id];
this._usedParts = next;
}} />
<span>${pt.name}${pt.stock !== null && pt.stock !== undefined ? ` (${pt.stock}${pt.unit ? " " + pt.unit : ""})` : ""}</span>
</label>
${checked
? html`<input class="used-part-qty" type="number" min="0.01" max="999" step="0.01"
.value=${String(qty)}
@input=${(e: Event) => {
const v = parseFloat((e.target as HTMLInputElement).value);
this._usedParts = { ...this._usedParts, [pt.id]: Number.isFinite(v) && v >= 0.01 ? v : 1 };
}} />`
: nothing}
</div>`;
})}
</div>`
: this.consumesInfo.length
? html`<div class="consumes-hint">
${this.consumesInfo.map((line) => html`<div>${line}</div>`)}
</div>`
: nothing}
${this.restockDefault !== null
? html`
<label class="field">
<span class="field-label">${t("restock_quantity_label", L)}</span>
<input type="number" step="0.01" min="0.01" class="field-input"
.value=${this._restockQty}
@input=${(e: Event) => (this._restockQty = (e.target as HTMLInputElement).value)} />
</label>`
: nothing}
<!-- Native <input>s rather than <ha-textfield>: when this dialog
is opened from a Lovelace card via dialog-mount, ha-textfield
isn't yet registered (HA loads it lazily when its own panels
need it) so the elements render with zero height and the user
only sees the title + Cancel/Complete buttons — the original
bug report. Native inputs always render. -->
<label class="field">
<span class="field-label">${t("notes_optional", L)}</span>
<input type="text" class="field-input"
.value=${this._notes}
@input=${(e: Event) => (this._notes = (e.target as HTMLInputElement).value)} />
</label>
<label class="field">
<span class="field-label">${t("cost_optional", L)}</span>
<input type="number" step="0.01" min="0" class="field-input"
.value=${this._cost}
@input=${(e: Event) => (this._cost = (e.target as HTMLInputElement).value)} />
</label>
<label class="field">
<span class="field-label">${t("duration_minutes", L)}</span>
<input type="number" step="0.01" min="0" class="field-input"
.value=${this._duration}
@input=${(e: Event) => (this._duration = (e.target as HTMLInputElement).value)} />
</label>
<div class="field">
<span class="field-label">${t("completion_photo_optional", L)}</span>
${this._photoPreview
? html`
<div class="photo-preview">
<img src=${this._photoPreview} alt="" />
<button type="button" class="photo-remove" @click=${this._removePhoto}
title="${t("remove", L)}">✕</button>
</div>`
: html`
<label class="photo-pick">
<ha-icon icon="mdi:camera"></ha-icon>
<span>${this._photoUploading ? t("uploading", L) : t("add_photo", L)}</span>
<input type="file" accept="image/*" capture="environment"
?disabled=${this._photoUploading}
@change=${this._onPhotoInput} />
</label>`}
</div>
${this.adaptiveEnabled ? html`
<div class="feedback-section">
<label class="feedback-label">${t("was_maintenance_needed", L)}</label>
<div class="feedback-buttons">
<button
class="feedback-btn ${this._feedback === "needed" ? "selected" : ""}"
@click=${() => this._setFeedback("needed")}
>${t("feedback_needed", L)}</button>
<button
class="feedback-btn ${this._feedback === "not_needed" ? "selected" : ""}"
@click=${() => this._setFeedback("not_needed")}
>${t("feedback_not_needed", L)}</button>
<button
class="feedback-btn ${this._feedback === "not_sure" ? "selected" : ""}"
@click=${() => this._setFeedback("not_sure")}
>${t("feedback_not_sure", L)}</button>
</div>
</div>
` : nothing}
</div>
<div class="dialog-actions">
<ha-button appearance="plain" @click=${this._close}>
${t("cancel", L)}
</ha-button>
<ha-button
@click=${this._complete}
.disabled=${this._loading}
>
${this._loading ? t("completing", L) : t("complete", L)}
</ha-button>
</div>
</ha-dialog>
`;
}
static styles = [nativeFieldStyles, css`
.dialog-title {
font-size: 18px;
font-weight: 500;
padding-bottom: 12px;
}
.content {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 300px;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 16px;
}
.consumes-hint {
font-size: 13px;
color: var(--secondary-text-color);
border-left: 3px solid var(--primary-color);
padding: 4px 8px;
margin: 4px 0 8px;
}
/* #99: editable per-completion parts selection */
.used-parts { margin: 4px 0 8px; display: flex; flex-direction: column; gap: 4px; }
.used-part-row { display: flex; align-items: center; gap: 8px; }
.used-part-check {
display: flex; align-items: center; gap: 6px; flex: 1;
font-size: 13px; cursor: pointer;
}
.used-part-check input { cursor: pointer; }
.used-part-qty {
width: 76px; padding: 4px 6px; border-radius: 4px; font: inherit; font-size: 13px;
border: 1px solid var(--divider-color);
background: var(--card-background-color);
color: var(--primary-text-color);
}
.error {
color: var(--error-color, #f44336);
font-size: 13px;
}
/* .field/.field-label/.field-input come from nativeFieldStyles */
.photo-pick {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border: 1px dashed var(--divider-color);
border-radius: 8px;
cursor: pointer;
font-size: 13px;
color: var(--secondary-text-color);
width: fit-content;
}
.photo-pick:hover { border-color: var(--primary-color); }
.photo-pick input[type="file"] { display: none; }
.photo-preview {
position: relative;
width: fit-content;
}
.photo-preview img {
max-width: 160px;
max-height: 160px;
border-radius: 8px;
display: block;
}
.photo-remove {
position: absolute;
top: -8px;
right: -8px;
width: 24px;
height: 24px;
border-radius: 50%;
border: none;
background: var(--error-color, #db4437);
color: #fff;
cursor: pointer;
font-size: 12px;
line-height: 1;
}
.checklist-section {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px 0;
border-bottom: 1px solid var(--divider-color);
margin-bottom: 4px;
}
.checklist-label {
font-weight: 500;
font-size: 13px;
color: var(--secondary-text-color);
}
.checklist-item {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 4px 0;
font-size: 14px;
}
.checklist-item input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
}
.feedback-section {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px 0;
border-top: 1px solid var(--divider-color);
}
.feedback-label {
font-weight: 500;
font-size: 13px;
color: var(--secondary-text-color);
}
.feedback-buttons {
display: flex;
gap: 8px;
}
.feedback-btn {
flex: 1;
padding: 8px 12px;
border: 1px solid var(--divider-color);
border-radius: 8px;
background: var(--card-background-color, #fff);
color: var(--primary-text-color);
font-size: 13px;
cursor: pointer;
text-align: center;
transition: all 0.2s;
}
.feedback-btn:hover {
background: var(--secondary-background-color, #f5f5f5);
}
.feedback-btn.selected {
background: var(--primary-color);
color: var(--text-primary-color, #fff);
border-color: var(--primary-color);
}
`];
}
// Safe registration — avoids duplicate define when both panel and card load
if (!customElements.get("maintenance-complete-dialog")) {
customElements.define("maintenance-complete-dialog", MaintenanceCompleteDialog);
}