189 files

This commit is contained in:
Home Assistant Version Control
2026-09-08 18:05:32 +00:00
parent 79fefc0a7c
commit 303886f212
189 changed files with 21402 additions and 3514 deletions
@@ -2,11 +2,18 @@
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import type { HomeAssistant, TaskPartLink } from "../types";
import { t, nativeFieldStyles, formatCost } from "../styles";
import type { HomeAssistant, ReadingSlot, TaskPartLink } from "../types";
import { lastReadingBefore, type ReadingHistoryEntry } from "../helpers/reading-slots";
import { t, nativeFieldStyles, formatCost, formatNumber } from "../styles";
import { describeWsError } from "../ws-errors";
import { partLinkKey, type LinkedPart } from "../helpers/shared-parts";
import { isAndroidCompanion } from "../helpers/companion";
import { REQUIRED_COMPLETION_LABELS } from "./required-completion-labels";
import {
MAX_COMPLETION_PHOTOS,
discardUploadedPhotos,
uploadCompletionPhoto,
} from "../helpers/photo-upload";
import "./ms-date-field";
export class MaintenanceCompleteDialog extends LitElement {
@@ -20,6 +27,14 @@ export class MaintenanceCompleteDialog extends LitElement {
// v2.20 (#83): task type + unit drive the reading-value field below.
@property() public taskType = "";
@property() public readingUnit = "";
/** #161 phase 2: the task's reading slots — one field each instead of
* the single value; [] keeps the scalar field. */
@property({ attribute: false }) public readings: ReadingSlot[] = [];
/** The task's dated slot snapshots (oldest first) — the "last: …" hint
* and the lower-than-last warning compare against the last value BEFORE
* the completion moment, so a backdated entry is judged by its own
* date rather than by the newest reading. */
@property({ attribute: false }) public readingHistory: ReadingHistoryEntry[] = [];
/** Buy task (part_ref): default restock quantity — shows an editable qty field. */
@property({ attribute: false }) public restockDefault: number | null = null;
/** #104 follow-up: the buy task's part unit cost — powers the cost
@@ -61,10 +76,15 @@ export class MaintenanceCompleteDialog extends LitElement {
@state() private _error = "";
@state() private _checklistState: Record<string, boolean> = {};
@state() private _feedback: string = "needed";
@state() private _photoDocId = "";
@state() private _photoPreview = "";
/** #161: the photos attached so far, in pick order (preview = object URL). */
@state() private _photos: Array<{ id: string; preview: string }> = [];
/** Docs uploaded by THIS dialog session; dropped again on Cancel so an
* abandoned completion leaves no orphan files behind. */
private _uploadedIds: string[] = [];
@state() private _photoUploading = false;
@state() private _readingValue = "";
/** #161 phase 2: typed text per slot id (parsed on save; "" = unread). */
@state() private _readingValues: Record<string, string> = {};
@state() private _restockQty = "";
/** #133: optional backdated completion moment ("YYYY-MM-DDTHH:MM:SS" local; "" = now). */
@state() private _completedAt = "";
@@ -94,10 +114,12 @@ export class MaintenanceCompleteDialog extends LitElement {
.filter(([, done]) => done),
);
this._feedback = "needed";
this._photoDocId = "";
this._photoPreview = "";
this._photos.forEach((p) => URL.revokeObjectURL(p.preview));
this._photos = [];
this._uploadedIds = [];
this._photoUploading = false;
this._readingValue = "";
this._readingValues = {};
this._restockQty = this.restockDefault !== null ? String(this.restockDefault) : "";
this._completedAt = "";
// #99: prefill "parts used" with the task's fixed links — the user can
@@ -118,45 +140,52 @@ export class MaintenanceCompleteDialog extends LitElement {
this._feedback = value;
}
/** The Android Companion app answers a multi-select with an empty file
* list (its chooser ignores the intent's ClipData — #161 follow-up), so
* the gallery picker is single-select there and a hint says each pick
* is added. Evaluated once per dialog; the host does not change. */
private readonly _singlePick = isAndroidCompanion();
/** #161: both pickers (camera = one shot, gallery = multiple) land here.
* Files upload one after another so a slow connection still shows
* progress tile by tile; anything beyond the cap is dropped with a
* note rather than silently. */
private async _onPhotoInput(e: Event): Promise<void> {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
const files = Array.from(input.files ?? []);
input.value = ""; // allow re-picking the same file
if (!file) return;
if (files.length === 0) return;
const room = MAX_COMPLETION_PHOTOS - this._photos.length;
const accepted = files.slice(0, Math.max(room, 0));
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;
for (const file of accepted) {
const id = await uploadCompletionPhoto(this.hass, this.entryId, file);
this._uploadedIds = [...this._uploadedIds, id];
this._photos = [...this._photos, { id, preview: URL.createObjectURL(file) }];
}
const doc = (await resp.json()) as { id?: string };
if (doc.id) {
this._photoDocId = doc.id;
this._photoPreview = URL.createObjectURL(file);
if (files.length > accepted.length) {
this._error = t("photos_limit", this.lang).replace("{max}", String(MAX_COMPLETION_PHOTOS));
}
} catch {
this._error = t("doc_upload_failed", this.lang);
} catch (e) {
const key = e instanceof Error && e.message === "doc_too_large" ? "doc_too_large" : "doc_upload_failed";
this._error = t(key, this.lang);
} finally {
this._photoUploading = false;
}
}
private _removePhoto(): void {
if (this._photoPreview) URL.revokeObjectURL(this._photoPreview);
this._photoDocId = "";
this._photoPreview = "";
/** ✕ on a tile: drop it from the completion AND delete the upload —
* the file only ever existed for this dialog session. */
private _removePhoto(id: string): void {
const gone = this._photos.find((p) => p.id === id);
if (gone) URL.revokeObjectURL(gone.preview);
this._photos = this._photos.filter((p) => p.id !== id);
if (this._uploadedIds.includes(id)) {
this._uploadedIds = this._uploadedIds.filter((x) => x !== id);
void discardUploadedPhotos(this.hass, [id]);
}
}
private async _complete(): Promise<void> {
@@ -183,8 +212,8 @@ export class MaintenanceCompleteDialog extends LitElement {
if (this.adaptiveEnabled) {
data.feedback = this._feedback;
}
if (this._photoDocId) {
data.photo_doc_id = this._photoDocId;
if (this._photos.length > 0) {
data.photo_doc_ids = this._photos.map((p) => p.id);
}
// Scan fallback: the backend accepts via_tag_scan on task/complete so a
// require_tag_scan task can still be finished from the dialog the scan
@@ -204,7 +233,18 @@ export class MaintenanceCompleteDialog extends LitElement {
// normalisation as the history-edit dialog).
data.completed_at = this._completedAt.length === 16 ? `${this._completedAt}:00` : this._completedAt;
}
if (this._readingValue !== "") {
if (this.readings.length > 0) {
// #161 phase 2: only the meters actually read go out; the backend
// snapshots name + unit per slot id.
const values: Record<string, number> = {};
for (const slot of this.readings) {
const raw = (this._readingValues[slot.id] ?? "").trim();
if (raw === "") continue;
const num = parseFloat(raw.replace(",", "."));
if (!isNaN(num)) values[slot.id] = num;
}
if (Object.keys(values).length > 0) data.reading_values = values;
} else if (this._readingValue !== "") {
const rv = parseFloat(this._readingValue);
if (!isNaN(rv)) data.reading_value = rv;
}
@@ -226,6 +266,7 @@ export class MaintenanceCompleteDialog extends LitElement {
);
}
await this.hass.connection.sendMessagePromise(data);
this._uploadedIds = []; // attached now — Cancel cleanup must not touch them
this._open = false;
this.dispatchEvent(new CustomEvent("task-completed"));
} catch (e) {
@@ -235,13 +276,37 @@ export class MaintenanceCompleteDialog extends LitElement {
}
}
/** #161 phase 2: one field per slot with the previous value as a hint;
* a value below the last one gets a warning (meters count up — a
* replaced meter or a typo), never a block. */
private _renderReadingField(slot: ReadingSlot, L: string) {
const at = this._completedAt ? new Date(this._completedAt).getTime() : NaN;
const last = lastReadingBefore(this.readingHistory, slot.id, isNaN(at) ? undefined : at);
const unit = slot.unit || this.readingUnit;
const raw = (this._readingValues[slot.id] ?? "").trim();
const num = raw === "" ? NaN : parseFloat(raw.replace(",", "."));
const below = last !== undefined && !isNaN(num) && num < last.value;
const lastText = last !== undefined ? formatNumber(last.value, L, { maximumFractionDigits: 3 }) : "";
return html`
<label class="field reading-field">
<span class="field-label">${slot.name}${unit ? ` (${unit})` : ""}</span>
<input type="text" inputmode="decimal" class="field-input"
placeholder=${last !== undefined ? t("reading_last", L).replace("{value}", lastText) : ""}
.value=${this._readingValues[slot.id] ?? ""}
@input=${(e: Event) => {
this._readingValues = { ...this._readingValues, [slot.id]: (e.target as HTMLInputElement).value };
}} />
${below ? html`<span class="reading-warn">${t("reading_below_last", L).replace("{value}", lastText)}</span>` : nothing}
</label>`;
}
/** Required details the user has not supplied yet (drives Save + markers). */
private get _missingRequired(): string[] {
const filled: Record<string, boolean> = {
notes: this._notes.trim() !== "",
cost: this._cost.trim() !== "",
duration: this._duration.trim() !== "",
photo: this._photoDocId !== "",
photo: this._photos.length > 0,
// "Who did it" is filled in server-side from the authenticated
// connection (websocket/tasks_actions.py), so the dialog satisfies it
// as long as we ARE a logged-in user. Claiming it is always satisfied
@@ -300,6 +365,11 @@ export class MaintenanceCompleteDialog extends LitElement {
private _close(): void {
this._open = false;
if (this._uploadedIds.length > 0) {
const orphans = this._uploadedIds;
this._uploadedIds = [];
void discardUploadedPhotos(this.hass, orphans);
}
}
/** Seed the backdate field with the current minute (local, seconds zeroed). */
@@ -330,7 +400,12 @@ export class MaintenanceCompleteDialog extends LitElement {
`)}
</div>
` : nothing}
${this.taskType === "reading"
${this.readings.length > 0
? html`<div class="readings-block">
<span class="field-label">${t("readings_section", L)}</span>
${this.readings.map((slot) => this._renderReadingField(slot, L))}
</div>`
: this.taskType === "reading"
? html`
<label class="field">
<span class="field-label">${t("reading_value_label", L)}${this.readingUnit ? ` (${this.readingUnit})` : ""}</span>
@@ -433,22 +508,36 @@ export class MaintenanceCompleteDialog extends LitElement {
</button>`}
</div>
<div class="field">
<span class="field-label">${t("completion_photo_optional", L)}${this._req("photo")}</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>
<span class="field-label">${t("completion_photos_optional", L)}${this._req("photo")}</span>
${this._photos.length > 0
? html`<div class="photo-strip">
${this._photos.map((p) => html`
<div class="photo-preview">
<img src=${p.preview} alt="" />
<button type="button" class="photo-remove" @click=${() => this._removePhoto(p.id)}
title="${t("remove", L)}">✕</button>
</div>`)}
</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>`}
: nothing}
${this._photos.length < MAX_COMPLETION_PHOTOS
? html`<div class="photo-pickers">
<label class="photo-pick photo-pick-camera">
<ha-icon icon="mdi:camera"></ha-icon>
<span>${this._photoUploading ? t("uploading", L) : t("doc_camera", L)}</span>
<input type="file" accept="image/*" capture="environment"
?disabled=${this._photoUploading}
@change=${this._onPhotoInput} />
</label>
<label class="photo-pick photo-pick-gallery">
<ha-icon icon="mdi:image-multiple"></ha-icon>
<span>${t(this._singlePick ? "choose_photo" : "choose_photos", L)}</span>
<input type="file" accept="image/*" ?multiple=${!this._singlePick}
?disabled=${this._photoUploading}
@change=${this._onPhotoInput} />
</label>
</div>
${this._singlePick ? html`<div class="photo-limit photo-android-hint">${t("photos_android_hint", L)}</div>` : nothing}`
: html`<div class="photo-limit">${t("photos_limit", L).replace("{max}", String(MAX_COMPLETION_PHOTOS))}</div>`}
</div>
${this.adaptiveEnabled ? html`
<div class="feedback-section">
@@ -567,6 +656,13 @@ export class MaintenanceCompleteDialog extends LitElement {
font-size: 13px;
}
/* .field/.field-label/.field-input come from nativeFieldStyles */
/* #161 phase 2: the per-slot reading fields */
.readings-block { display: flex; flex-direction: column; gap: 8px; }
.readings-block > .field-label { margin-bottom: -4px; }
.reading-warn {
font-size: 12px;
color: var(--warning-color, #ff9800);
}
.photo-pick {
display: inline-flex;
align-items: center;
@@ -601,15 +697,37 @@ export class MaintenanceCompleteDialog extends LitElement {
}
.backdate-pick:hover { border-color: var(--primary-color); }
.photo-pick input[type="file"] { display: none; }
/* #161: several photos per completion — tiles wrap into a strip,
the two pickers (camera / gallery) sit underneath while there is
room left under the cap. */
.photo-pickers {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.photo-strip {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin: 8px 0; /* room for the remove badges above the tiles */
}
.photo-limit {
font-size: 12px;
color: var(--secondary-text-color);
}
.photo-android-hint { margin-top: 4px; }
.photo-preview {
position: relative;
width: fit-content;
}
/* Uniform tiles: a tiny or portrait shot must not collapse the strip. */
.photo-preview img {
max-width: 160px;
max-height: 160px;
width: 96px;
height: 96px;
object-fit: cover;
border-radius: 8px;
display: block;
background: var(--secondary-background-color, rgba(0,0,0,0.06));
}
.photo-remove {
position: absolute;