469 lines
17 KiB
TypeScript
469 lines
17 KiB
TypeScript
/** 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);
|
||
}
|