Files
HomeAssistantVS/custom_components/maintenance_supporter/frontend-src/components/complete-dialog.ts
T
2026-07-08 10:43:39 -04:00

397 lines
13 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 } 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 = "";
@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 = "";
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 = "";
}
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;
}
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}
<!-- 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="1" 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 = 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;
}
.error {
color: var(--error-color, #f44336);
font-size: 13px;
}
.field { display: flex; flex-direction: column; gap: 4px; }
.field-label {
font-size: 12px;
color: var(--secondary-text-color);
}
.field-input {
padding: 8px 10px; font-size: 14px;
background: var(--secondary-background-color, rgba(0,0,0,0.06));
color: var(--primary-text-color);
border: 1px solid var(--divider-color); border-radius: 6px;
font-family: inherit;
width: 100%; box-sizing: border-box;
}
.field-input:focus {
outline: none;
border-color: var(--primary-color);
}
.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);
}