189 files
This commit is contained in:
+172
-54
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user