189 files
This commit is contained in:
+75
-4
@@ -35,6 +35,14 @@ interface BatteryRow {
|
||||
* healthy. Never escalates to low/task — the row just shows the
|
||||
* discrepancy (usual cause: an unrecorded swap). */
|
||||
forecast_overdue?: boolean;
|
||||
/** D#162: a Battery Notes note whose device has NO battery level at
|
||||
* all — read from its type sensor. No level bar, never offline; the
|
||||
* forecast is the whole signal and (option) a passed one is `low`. */
|
||||
no_sensor?: boolean;
|
||||
/** D#162 follow-up: the note has a replaced button → per-row Replaced action. */
|
||||
can_mark_replaced?: boolean;
|
||||
/** ISO date the forecast anchors on (Battery Notes' last replaced). */
|
||||
last_replaced?: string | null;
|
||||
}
|
||||
interface RosterRow extends BatteryRow {
|
||||
status: "low" | "soon" | "ok";
|
||||
@@ -69,6 +77,9 @@ interface Overview {
|
||||
excluded?: { entity_id: string; device_name: string }[];
|
||||
// #135 follow-up: fleet-wide "track self-charging devices" opt-in.
|
||||
track_self_charging?: boolean;
|
||||
// D#162: a passed forecast on a sensorless note counts as due (default
|
||||
// on — an older backend omits the field, which renders as on too).
|
||||
due_without_sensor?: boolean;
|
||||
}
|
||||
|
||||
export class MaintenanceBatteryFleetSection extends LitElement {
|
||||
@@ -213,13 +224,22 @@ export class MaintenanceBatteryFleetSection extends LitElement {
|
||||
// #135 follow-up: fleet-wide opt-in that keeps self-charging devices
|
||||
// (phones, vacuums, smart rings) in the roster as rechargeables.
|
||||
private async _setTrackSelf(e: Event): Promise<void> {
|
||||
const enabled = (e.target as HTMLInputElement).checked;
|
||||
await this._setFleetOption("set_track_self_charging", (e.target as HTMLInputElement).checked);
|
||||
}
|
||||
|
||||
// D#162: fleet-wide option — a passed forecast on a sensorless note is
|
||||
// due (fires the task). Default on; only the opt-out is stored.
|
||||
private async _setDueWithoutSensor(e: Event): Promise<void> {
|
||||
await this._setFleetOption("set_due_without_sensor", (e.target as HTMLInputElement).checked);
|
||||
}
|
||||
|
||||
private async _setFleetOption(command: "set_track_self_charging" | "set_due_without_sensor", enabled: boolean): Promise<void> {
|
||||
if (this._marking) return;
|
||||
this._marking = true;
|
||||
this._error = "";
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/battery_fleet/set_track_self_charging",
|
||||
type: `maintenance_supporter/battery_fleet/${command}`,
|
||||
enabled,
|
||||
});
|
||||
await this._load();
|
||||
@@ -428,7 +448,9 @@ export class MaintenanceBatteryFleetSection extends LitElement {
|
||||
<span class="bf-dev">${b.device_name}</span>
|
||||
${b.available === false
|
||||
? html`<span class="bf-offline">${t("battery_fleet_offline", L)}</span>`
|
||||
: nothing}
|
||||
: b.no_sensor
|
||||
? html`<span class="bf-offline bf-nosensor">${t("battery_fleet_no_sensor", L)}</span>`
|
||||
: nothing}
|
||||
<span class="bf-type">${b.quantity}× ${b.battery_type}</span>
|
||||
${b.rechargeable
|
||||
? html`<span class="bf-recharge" title=${t("battery_fleet_rechargeable", L)}
|
||||
@@ -496,8 +518,15 @@ export class MaintenanceBatteryFleetSection extends LitElement {
|
||||
(b) => html`
|
||||
<div class="bf-row">
|
||||
<span class="bf-dev">${b.device_name}</span>
|
||||
<span class="bf-status bf-${b.status}">${t("battery_fleet_status_" + b.status, L)}</span>
|
||||
<span class="bf-status bf-${b.status}"
|
||||
>${b.no_sensor && b.status === "low"
|
||||
? t("battery_fleet_status_due", L)
|
||||
: t("battery_fleet_status_" + b.status, L)}</span
|
||||
>
|
||||
<span class="bf-type">${b.quantity}× ${b.battery_type}</span>
|
||||
${b.no_sensor
|
||||
? html`<span class="bf-offline bf-nosensor">${t("battery_fleet_no_sensor", L)}</span>`
|
||||
: nothing}
|
||||
${b.rechargeable
|
||||
? html`<span class="bf-recharge" title=${t("battery_fleet_rechargeable", L)}
|
||||
><ha-icon icon="mdi:battery-charging-outline"></ha-icon
|
||||
@@ -506,6 +535,16 @@ export class MaintenanceBatteryFleetSection extends LitElement {
|
||||
${this._sparkline(b)}
|
||||
${this._levelBar(b)}
|
||||
${b.level != null ? html`<span class="bf-level">${b.level}%</span>` : nothing}
|
||||
${b.no_sensor || b.can_mark_replaced
|
||||
? html`<button
|
||||
class="bf-mark bf-replaced"
|
||||
title=${t("battery_fleet_mark_one", L)}
|
||||
.disabled=${this._marking}
|
||||
@click=${() => this._mark([b.entity_id])}
|
||||
>
|
||||
<ha-icon icon="mdi:battery-sync"></ha-icon>
|
||||
</button>`
|
||||
: nothing}
|
||||
${(() => {
|
||||
const jump = this._history?.[b.entity_id]?.jump;
|
||||
if (!jump || this._recorded.includes(b.entity_id)) return nothing;
|
||||
@@ -564,6 +603,16 @@ export class MaintenanceBatteryFleetSection extends LitElement {
|
||||
${t("battery_fleet_track_self", L)}
|
||||
</label>
|
||||
<div class="bf-roster-hint">${t("battery_fleet_track_self_hint", L)}</div>
|
||||
<label class="bf-track-self bf-due-nosensor">
|
||||
<input
|
||||
type="checkbox"
|
||||
.checked=${ov.due_without_sensor !== false}
|
||||
.disabled=${this._marking}
|
||||
@change=${this._setDueWithoutSensor}
|
||||
/>
|
||||
${t("battery_fleet_due_without_sensor", L)}
|
||||
</label>
|
||||
<div class="bf-roster-hint">${t("battery_fleet_due_without_sensor_hint", L)}</div>
|
||||
</details>
|
||||
`
|
||||
: nothing}
|
||||
@@ -712,6 +761,13 @@ export class MaintenanceBatteryFleetSection extends LitElement {
|
||||
.bf-bar {
|
||||
grid-column: 6;
|
||||
}
|
||||
/* D#162: the roster's "No sensor" chip takes the level bar's slot
|
||||
* (such a row has no bar); column 2 stays the status chip's. */
|
||||
.bf-status + .bf-type + .bf-nosensor {
|
||||
grid-column: 6;
|
||||
justify-self: start;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.bf-level {
|
||||
grid-column: 7;
|
||||
justify-self: end;
|
||||
@@ -719,6 +775,14 @@ export class MaintenanceBatteryFleetSection extends LitElement {
|
||||
.bf-row .bf-mark {
|
||||
grid-column: 8;
|
||||
}
|
||||
/* D#162: the sensorless row's Replaced action sits in the percentage
|
||||
* slot (always empty for such a row) instead of the row-action column
|
||||
* - a new max-content track there widens EVERY row via the subgrid
|
||||
* and pushed the phone roster 34 px past its edge. */
|
||||
.bf-row .bf-mark.bf-replaced {
|
||||
grid-column: 7;
|
||||
justify-self: end;
|
||||
}
|
||||
.bf-predicted {
|
||||
grid-column: 9;
|
||||
justify-self: end;
|
||||
@@ -791,6 +855,13 @@ export class MaintenanceBatteryFleetSection extends LitElement {
|
||||
.bf-row .bf-mark {
|
||||
grid-row: 2;
|
||||
}
|
||||
/* The roster's "No sensor" chip would widen the shared bar track
|
||||
* for EVERY row (subgrid) - on phones the missing percentage and
|
||||
* the "Due" status already tell the story, so it yields like the
|
||||
* bar and the sparkline do. */
|
||||
.bf-status + .bf-type + .bf-nosensor {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.bf-spark-line {
|
||||
fill: none;
|
||||
|
||||
+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;
|
||||
|
||||
+24
-5
@@ -16,6 +16,7 @@ import { downloadUrl } from "../helpers/download";
|
||||
import { downloadSignedDocument, openSignedDocument, signDocumentPath } from "../helpers/document-url";
|
||||
import { formatBytes } from "../helpers/format-bytes";
|
||||
import { docDisplayName, CATEGORIES, CATEGORY_ICONS } from "../helpers/document-categories";
|
||||
import { DOC_FILTER_MIN, filterDocuments } from "../helpers/document-filter";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
interface MaintenanceDocument {
|
||||
@@ -36,6 +37,8 @@ export class MaintenanceDocumentsSection extends LitElement {
|
||||
@property({ type: Boolean }) public canWrite = false;
|
||||
|
||||
@state() private _docs: MaintenanceDocument[] = [];
|
||||
// Local filter (#171) — shown once the list is long enough to need one.
|
||||
@state() private _filter = "";
|
||||
@state() private _loaded = false;
|
||||
@state() private _busy = false;
|
||||
@state() private _error = "";
|
||||
@@ -389,15 +392,23 @@ export class MaintenanceDocumentsSection extends LitElement {
|
||||
`
|
||||
: nothing}
|
||||
|
||||
${this._loaded && this._docs.length >= DOC_FILTER_MIN
|
||||
? html`<div class="doc-filter">
|
||||
<ha-icon icon="mdi:magnify"></ha-icon>
|
||||
<input type="search" aria-label=${t("doc_search", L)} placeholder=${t("doc_search", L)}
|
||||
.value=${this._filter} @input=${(e: Event) => (this._filter = (e.target as HTMLInputElement).value)} />
|
||||
</div>`
|
||||
: nothing}
|
||||
${!this._loaded
|
||||
? html`<div class="doc-empty">${t("loading", L)}</div>`
|
||||
: this._docs.length === 0
|
||||
? html`<div class="doc-empty">${t("documents_empty", L)}</div>`
|
||||
: html`
|
||||
<div class="doc-list">
|
||||
${this._docs.map((doc) => this._renderDoc(doc, L))}
|
||||
</div>
|
||||
`}
|
||||
: (() => {
|
||||
const shown = filterDocuments(this._docs, this._filter);
|
||||
return shown.length === 0
|
||||
? html`<div class="doc-empty">${t("doc_search_none", L)}</div>`
|
||||
: html`<div class="doc-list">${shown.map((doc) => this._renderDoc(doc, L))}</div>`;
|
||||
})()}
|
||||
|
||||
${this._lightboxUrl
|
||||
? html`<div class="lightbox" @click=${() => (this._lightboxUrl = "")}>
|
||||
@@ -561,6 +572,14 @@ export class MaintenanceDocumentsSection extends LitElement {
|
||||
.doc-msg.error { color: var(--error-color, #f44336); }
|
||||
.doc-msg.hint { color: var(--secondary-text-color, #888); }
|
||||
.doc-empty { color: var(--secondary-text-color, #888); font-size: 13px; padding: 8px 0; }
|
||||
.doc-filter { display: flex; align-items: center; gap: 6px; margin: 4px 0 8px; }
|
||||
.doc-filter ha-icon { --mdc-icon-size: 18px; color: var(--secondary-text-color, #888); }
|
||||
.doc-filter input {
|
||||
flex: 1; min-width: 0; font: inherit; font-size: 13px; padding: 6px 8px; border-radius: 6px;
|
||||
border: 1px solid var(--divider-color); background: var(--card-background-color, #fff);
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
.doc-filter input:focus { outline: none; border-color: var(--primary-color); }
|
||||
.doc-list { display: flex; flex-direction: column; gap: 4px; }
|
||||
.doc-row {
|
||||
display: flex; align-items: center; gap: 12px; padding: 8px 10px;
|
||||
|
||||
+225
-3
@@ -1,5 +1,5 @@
|
||||
/** Dialog for editing an existing history entry (timestamp / notes / cost /
|
||||
* duration / completed_by). Backed by maintenance_supporter/task/history/update.
|
||||
* duration / completed_by / photos). Backed by maintenance_supporter/task/history/update.
|
||||
*
|
||||
* Opened from:
|
||||
* - Task detail page → history tab → Edit button per entry
|
||||
@@ -9,10 +9,16 @@
|
||||
|
||||
import { LitElement, html, css, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { t, langOf } from "../styles";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { t, langOf, formatNumber } from "../styles";
|
||||
import type { HomeAssistant, ReadingSlot, ReadingValue } from "../types";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import {
|
||||
MAX_COMPLETION_PHOTOS,
|
||||
discardUploadedPhotos,
|
||||
uploadCompletionPhoto,
|
||||
} from "../helpers/photo-upload";
|
||||
import "./ms-date-field";
|
||||
import "./history-photo";
|
||||
|
||||
export interface HistoryEntryDraft {
|
||||
entry_id: string;
|
||||
@@ -27,6 +33,19 @@ export interface HistoryEntryDraft {
|
||||
// #130: the entry's recorded part consumption ({part_id, name, quantity,
|
||||
// entry_id? for pooled parts}); absent/empty = nothing consumed.
|
||||
used_parts?: Array<{ part_id: string; name?: string; quantity: number; entry_id?: string }> | null;
|
||||
// #161: the entry's completion photos (callers fold the pre-multi-photo
|
||||
// `photo_doc_id` scalar in via helpers/history-photos.historyPhotoIds).
|
||||
photo_doc_ids?: string[] | null;
|
||||
// #161 phase 2: the entry's readings — the v2.20 scalar and/or the
|
||||
// per-slot snapshot — plus the task's current slots, so a meter that
|
||||
// was skipped at completion can be filled in afterwards. task_type /
|
||||
// reading_unit decide whether the scalar field shows on an entry
|
||||
// that recorded nothing.
|
||||
reading_value?: number | null;
|
||||
reading_values?: ReadingValue[] | null;
|
||||
readings?: ReadingSlot[] | null;
|
||||
task_type?: string | null;
|
||||
reading_unit?: string | null;
|
||||
}
|
||||
|
||||
/** One selectable part option in the edit dialog — the object's own parts
|
||||
@@ -60,6 +79,21 @@ export class MaintenanceHistoryEditDialog extends LitElement {
|
||||
@state() private _partQty: Record<string, number> = {};
|
||||
private _partQtyOriginal = "";
|
||||
|
||||
// #161: the edited photo list; the original as a JSON key for the
|
||||
// "did it change" check, plus the docs THIS session uploaded so an
|
||||
// abandoned edit can drop them again (a removed pre-existing photo is
|
||||
// only detached — the file stays in the object's documents).
|
||||
@state() private _photos: string[] = [];
|
||||
private _photosOriginal = "";
|
||||
|
||||
// #161 phase 2: the reading rows (entry snapshot ∪ task slots) with the
|
||||
// typed text per slot id; the original numbers as a JSON key.
|
||||
@state() private _readingRows: ReadingSlot[] = [];
|
||||
@state() private _readingText: Record<string, string> = {};
|
||||
private _readingsOriginal = "";
|
||||
private _uploadedIds: string[] = [];
|
||||
@state() private _photoUploading = false;
|
||||
|
||||
/** Open the dialog with the given history-entry data. The caller must
|
||||
* pass `original_timestamp` (the entry's current timestamp before edit)
|
||||
* so the backend can find the entry. */
|
||||
@@ -71,9 +105,83 @@ export class MaintenanceHistoryEditDialog extends LitElement {
|
||||
this._partOptions = null;
|
||||
this._partQty = {};
|
||||
this._partQtyOriginal = "";
|
||||
this._photos = [...(draft.photo_doc_ids ?? [])];
|
||||
this._photosOriginal = JSON.stringify(this._photos);
|
||||
this._uploadedIds = [];
|
||||
this._photoUploading = false;
|
||||
this._seedReadings(draft);
|
||||
void this._loadPartOptions();
|
||||
}
|
||||
|
||||
private _seedReadings(draft: HistoryEntryDraft): void {
|
||||
// Task slot order first (matches the complete dialog), then snapshot
|
||||
// slots the task no longer has — with the entry's own name/unit.
|
||||
const rows: ReadingSlot[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const s of draft.readings ?? []) {
|
||||
if (seen.has(s.id)) continue;
|
||||
seen.add(s.id);
|
||||
rows.push({ id: s.id, name: s.name, unit: s.unit ?? null });
|
||||
}
|
||||
for (const v of draft.reading_values ?? []) {
|
||||
if (seen.has(v.id)) continue;
|
||||
seen.add(v.id);
|
||||
rows.push({ id: v.id, name: v.name, unit: v.unit ?? null });
|
||||
}
|
||||
this._readingRows = rows;
|
||||
const text: Record<string, string> = {};
|
||||
for (const v of draft.reading_values ?? []) text[v.id] = String(v.value);
|
||||
this._readingText = text;
|
||||
this._readingsOriginal = JSON.stringify(this._readingNumbers());
|
||||
}
|
||||
|
||||
/** {slot id: number} for every row with a parsable value. */
|
||||
private _readingNumbers(): Record<string, number> {
|
||||
const out: Record<string, number> = {};
|
||||
for (const row of this._readingRows) {
|
||||
const raw = (this._readingText[row.id] ?? "").trim();
|
||||
if (raw === "") continue;
|
||||
const num = parseFloat(raw.replace(",", "."));
|
||||
if (!isNaN(num)) out[row.id] = num;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private async _onPhotoInput(e: Event): Promise<void> {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const files = Array.from(input.files ?? []);
|
||||
input.value = "";
|
||||
const draft = this._draft;
|
||||
if (files.length === 0 || !draft) return;
|
||||
const room = MAX_COMPLETION_PHOTOS - this._photos.length;
|
||||
const accepted = files.slice(0, Math.max(room, 0));
|
||||
this._photoUploading = true;
|
||||
this._error = "";
|
||||
try {
|
||||
for (const file of accepted) {
|
||||
const id = await uploadCompletionPhoto(this.hass, draft.entry_id, file);
|
||||
this._uploadedIds = [...this._uploadedIds, id];
|
||||
this._photos = [...this._photos, id];
|
||||
}
|
||||
if (files.length > accepted.length) {
|
||||
this._error = t("photos_limit", this._lang).replace("{max}", String(MAX_COMPLETION_PHOTOS));
|
||||
}
|
||||
} catch (err) {
|
||||
const key = err instanceof Error && err.message === "doc_too_large" ? "doc_too_large" : "doc_upload_failed";
|
||||
this._error = t(key, this._lang);
|
||||
} finally {
|
||||
this._photoUploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _removePhoto(id: string): void {
|
||||
this._photos = this._photos.filter((x) => x !== id);
|
||||
if (this._uploadedIds.includes(id)) {
|
||||
this._uploadedIds = this._uploadedIds.filter((x) => x !== id);
|
||||
void discardUploadedPhotos(this.hass, [id]);
|
||||
}
|
||||
}
|
||||
|
||||
/** The object's own parts + pooled parts this task draws on — from the
|
||||
* instance-wide overview so pooled owners resolve without extra calls. */
|
||||
private async _loadPartOptions(): Promise<void> {
|
||||
@@ -140,6 +248,12 @@ export class MaintenanceHistoryEditDialog extends LitElement {
|
||||
this._error = "";
|
||||
this._draft = null;
|
||||
this._originalSnapshot = null;
|
||||
if (this._uploadedIds.length > 0) {
|
||||
// Cancelled after uploading: nothing references those files.
|
||||
const orphans = this._uploadedIds;
|
||||
this._uploadedIds = [];
|
||||
void discardUploadedPhotos(this.hass, orphans);
|
||||
}
|
||||
}
|
||||
|
||||
private _set<K extends keyof HistoryEntryDraft>(
|
||||
@@ -188,6 +302,22 @@ export class MaintenanceHistoryEditDialog extends LitElement {
|
||||
...(o.foreign ? { entry_id: o.entry_id } : {}),
|
||||
}));
|
||||
}
|
||||
// #161 phase 2: readings. The scalar rides the changed-field check
|
||||
// like notes; the slot map is sent whole when any value differs —
|
||||
// the backend replaces the snapshot, a missing id means "unread".
|
||||
if (this._draft.reading_value !== this._originalSnapshot.reading_value) {
|
||||
patch.reading_value = this._draft.reading_value ?? null;
|
||||
}
|
||||
if (this._readingRows.length > 0 && JSON.stringify(this._readingNumbers()) !== this._readingsOriginal) {
|
||||
const numbers = this._readingNumbers();
|
||||
const map: Record<string, number | null> = {};
|
||||
for (const row of this._readingRows) map[row.id] = numbers[row.id] ?? null;
|
||||
patch.reading_values = map;
|
||||
}
|
||||
// #161: the photo list, only when it differs from what we opened with.
|
||||
if (JSON.stringify(this._photos) !== this._photosOriginal) {
|
||||
patch.photo_doc_ids = [...this._photos];
|
||||
}
|
||||
// Nothing changed → close without WS call
|
||||
const changedKeys = Object.keys(patch).filter(
|
||||
(k) => !["type", "entry_id", "task_id", "original_timestamp"].includes(k),
|
||||
@@ -197,6 +327,7 @@ export class MaintenanceHistoryEditDialog extends LitElement {
|
||||
return;
|
||||
}
|
||||
await this.hass.connection.sendMessagePromise(patch);
|
||||
this._uploadedIds = []; // saved — they belong to the entry now
|
||||
// Notify upstream so they can refresh
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("history-entry-saved", {
|
||||
@@ -273,6 +404,7 @@ export class MaintenanceHistoryEditDialog extends LitElement {
|
||||
}} />
|
||||
</label>
|
||||
</div>
|
||||
${this._renderReadings(d, L)}
|
||||
${this._partOptions && this._partOptions.length > 0 ? html`
|
||||
<div class="parts-block">
|
||||
<span class="parts-title">${t("complete_parts_used", L)}</span>
|
||||
@@ -300,6 +432,27 @@ export class MaintenanceHistoryEditDialog extends LitElement {
|
||||
})}
|
||||
</div>
|
||||
` : nothing}
|
||||
<div class="photos-block">
|
||||
<span class="parts-title">${t("completion_photos", L)}</span>
|
||||
${this._photos.length > 0 ? html`
|
||||
<div class="photo-strip">
|
||||
${this._photos.map((id) => html`
|
||||
<div class="photo-tile">
|
||||
<maintenance-history-photo .hass=${this.hass} .docId=${id}></maintenance-history-photo>
|
||||
<button type="button" class="photo-remove" title=${t("remove", L)}
|
||||
@click=${() => this._removePhoto(id)}>✕</button>
|
||||
</div>`)}
|
||||
</div>` : nothing}
|
||||
${this._photos.length < MAX_COMPLETION_PHOTOS ? html`
|
||||
<label class="photo-add">
|
||||
<ha-icon icon="mdi:image-plus"></ha-icon>
|
||||
<span>${this._photoUploading ? t("uploading", L) : t("add_photos", L)}</span>
|
||||
<input type="file" accept="image/*" multiple
|
||||
?disabled=${this._photoUploading}
|
||||
@change=${this._onPhotoInput} />
|
||||
</label>` : html`<span class="photos-hint">${t("photos_limit", L).replace("{max}", String(MAX_COMPLETION_PHOTOS))}</span>`}
|
||||
<span class="photos-hint">${t("history_edit_photos_hint", L)}</span>
|
||||
</div>
|
||||
${this._error ? html`<div class="error">${this._error}</div>` : nothing}
|
||||
<div class="actions">
|
||||
<button class="cancel" @click=${this.close} ?disabled=${this._saving}>
|
||||
@@ -313,6 +466,39 @@ export class MaintenanceHistoryEditDialog extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
/** #161 phase 2: per-slot rows for a slot task (entry snapshot ∪ current
|
||||
* slots), else the scalar field on a reading task / an entry that has one. */
|
||||
private _renderReadings(d: HistoryEntryDraft, L: string) {
|
||||
if (this._readingRows.length > 0) {
|
||||
return html`
|
||||
<div class="readings-block">
|
||||
<span class="parts-title">${t("readings_section", L)}</span>
|
||||
${this._readingRows.map((row) => html`
|
||||
<label class="reading-row-edit">
|
||||
<span class="reading-row-name">${row.name}${row.unit ? ` (${row.unit})` : ""}</span>
|
||||
<input type="text" inputmode="decimal" class="reading-row-input"
|
||||
.value=${this._readingText[row.id] ?? ""}
|
||||
@input=${(e: Event) => {
|
||||
this._readingText = { ...this._readingText, [row.id]: (e.target as HTMLInputElement).value };
|
||||
}} />
|
||||
</label>
|
||||
`)}
|
||||
</div>`;
|
||||
}
|
||||
if (d.reading_value == null && d.task_type !== "reading") return nothing;
|
||||
return html`
|
||||
<label>
|
||||
<span>${t("reading_value_label", L)}${d.reading_unit ? ` (${d.reading_unit})` : ""}</span>
|
||||
<input type="number" step="any"
|
||||
.value=${d.reading_value != null ? String(d.reading_value) : ""}
|
||||
@input=${(e: Event) => {
|
||||
const v = (e.target as HTMLInputElement).value;
|
||||
const num = v === "" ? NaN : Number(v);
|
||||
this._set("reading_value", isNaN(num) ? null : num);
|
||||
}} />
|
||||
</label>`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host { display: contents; }
|
||||
.backdrop {
|
||||
@@ -389,6 +575,42 @@ export class MaintenanceHistoryEditDialog extends LitElement {
|
||||
.part-row-edit input[type="checkbox"] { width: auto; }
|
||||
.part-label { flex: 1; color: var(--primary-text-color); }
|
||||
.part-qty { width: 76px; }
|
||||
/* #161 phase 2: readings on the entry */
|
||||
.readings-block {
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
border: 1px solid var(--divider-color, #444);
|
||||
border-radius: 6px; padding: 8px;
|
||||
}
|
||||
.reading-row-edit {
|
||||
display: flex; flex-direction: row; align-items: center; gap: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.reading-row-name { flex: 1; color: var(--primary-text-color); min-width: 0; }
|
||||
.reading-row-input { width: 140px; font-variant-numeric: tabular-nums; }
|
||||
/* #161: photos on the entry */
|
||||
.photos-block {
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
border: 1px solid var(--divider-color, #444);
|
||||
border-radius: 6px; padding: 8px;
|
||||
}
|
||||
.photo-strip { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 6px; }
|
||||
.photo-tile { position: relative; width: fit-content; }
|
||||
.photo-remove {
|
||||
position: absolute; top: -4px; right: -8px;
|
||||
width: 22px; height: 22px; border-radius: 50%; border: none;
|
||||
background: var(--error-color, #db4437); color: #fff;
|
||||
cursor: pointer; font-size: 11px; line-height: 1; padding: 0;
|
||||
}
|
||||
.photo-add {
|
||||
display: inline-flex; flex-direction: row; align-items: center; gap: 8px;
|
||||
width: fit-content; padding: 6px 10px;
|
||||
border: 1px dashed var(--divider-color, #444); border-radius: 8px;
|
||||
cursor: pointer; font-size: 13px; color: var(--secondary-text-color);
|
||||
--mdc-icon-size: 18px;
|
||||
}
|
||||
.photo-add:hover { border-color: var(--primary-color); }
|
||||
.photo-add input[type="file"] { display: none; }
|
||||
.photos-hint { font-size: 12px; color: var(--secondary-text-color); }
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -48,16 +48,20 @@ export class MaintenanceHistoryPhoto extends LitElement {
|
||||
|
||||
static styles = css`
|
||||
.wrap { display: inline-block; margin-top: 4px; }
|
||||
/* #161: uniform 96px tiles — several photos sit in a strip, so a
|
||||
tiny or portrait shot must not collapse its slot. */
|
||||
img {
|
||||
max-width: 96px;
|
||||
max-height: 96px;
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
display: block;
|
||||
border: 1px solid var(--divider-color);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.ph {
|
||||
width: 96px;
|
||||
height: 64px;
|
||||
height: 96px;
|
||||
border-radius: 6px;
|
||||
background: var(--secondary-background-color, rgba(0,0,0,0.06));
|
||||
margin-top: 4px;
|
||||
|
||||
@@ -5,12 +5,14 @@ import { property, state } from "lit/decorators.js";
|
||||
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
||||
import { live } from "lit/directives/live.js";
|
||||
import type { HomeAssistant, AdvancedFeatures, BudgetStatus, HAUser } from "../types";
|
||||
import { t, langOf } from "../styles";
|
||||
import { t, langOf, personStyles } from "../styles";
|
||||
import { signApiPath } from "../helpers/document-url";
|
||||
import { downloadUrl } from "../helpers/download";
|
||||
import { UserService } from "../user-service";
|
||||
import { AVATAR_PALETTE, personOf, renderPersonAvatar } from "../helpers/person";
|
||||
import { OBJECT_COLUMNS, sanitizeColumns } from "../helpers/object-columns";
|
||||
import { downloadTextFile } from "../helpers/download";
|
||||
import { invalidateSettingsCache } from "../helpers/settings-cache";
|
||||
import "./ms-date-field";
|
||||
|
||||
/** One household member and the notify services they actually resolve to.
|
||||
@@ -26,6 +28,8 @@ interface PersonNotifyTarget {
|
||||
interface SettingsResponse {
|
||||
features: AdvancedFeatures;
|
||||
admin_panel_user_ids?: string[];
|
||||
/** #169 follow-up: per-member avatar overrides. */
|
||||
member_display?: Record<string, { initials?: string; color?: string }>;
|
||||
operator_write_enabled?: boolean;
|
||||
objects_table_columns?: string[];
|
||||
/** v2.21: template-gallery curation — hidden template ids. */
|
||||
@@ -73,6 +77,9 @@ interface SettingsResponse {
|
||||
bundle_threshold: number;
|
||||
reminder_lead_days: number[];
|
||||
scope_view_id: string;
|
||||
/** #165: your own notification rule — event-only delivery + extra-data template. */
|
||||
event_only?: boolean;
|
||||
extra_data?: string;
|
||||
};
|
||||
actions: {
|
||||
complete_enabled: boolean;
|
||||
@@ -117,6 +124,8 @@ interface VacationPreviewRow {
|
||||
confidence: "deterministic" | "estimated" | "unpredictable";
|
||||
events: Array<{ date: string; status: "due_soon" | "overdue" | "triggered_est" }>;
|
||||
will_suppress: boolean;
|
||||
/** The task's own skip rule (#150); absent on older backends = allowed. */
|
||||
allow_skip?: boolean;
|
||||
}
|
||||
|
||||
// Keep in lockstep with const.BUDGET_CURRENCIES (keys + order). Parity is
|
||||
@@ -269,11 +278,18 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
.replace("{n}", String(bn.devices));
|
||||
const rule = (warn ? t("bn_above_floor", L).replace("{name}", "Battery Notes") : t("bn_floor_decides", L))
|
||||
.replace("{floor}", String(floor));
|
||||
// The integration name becomes the link, the rest stays text. ja/zh
|
||||
// punctuate with the fullwidth colon — an ASCII-only split linked the
|
||||
// whole sentence and then duplicated its last character (bug review
|
||||
// 2026-09-04).
|
||||
const cut = summary.search(/[::]/);
|
||||
const linkText = cut < 0 ? summary : summary.slice(0, cut);
|
||||
const rest = cut < 0 ? "" : summary.slice(cut);
|
||||
return html`
|
||||
<div class="bn-note${warn ? " warn" : ""}">
|
||||
<ha-icon icon="${warn ? "mdi:alert-outline" : "mdi:battery-heart-variant"}"></ha-icon>
|
||||
<span>
|
||||
<a class="bn-link" href="/config/integrations/integration/battery_notes">${summary.split(":")[0]}</a>${summary.slice(summary.indexOf(":"))}
|
||||
<a class="bn-link" href="/config/integrations/integration/battery_notes">${linkText}</a>${rest}
|
||||
${bn.overrides.length
|
||||
? html` · ${t("bn_overrides", L).replace("{n}", String(bn.overrides.length + bn.more))}
|
||||
${bn.overrides.map((o, i) => html`${i ? " · " : " "}${o.device_id
|
||||
@@ -286,6 +302,62 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/** #169 follow-up: Settings → household members — the avatar each member
|
||||
* gets in task lists (initials + a palette colour). Defaults come from
|
||||
* the backend (name / user id); an override is stored per user id. */
|
||||
private _renderMemberAvatars(L: string) {
|
||||
if (!this._users.length) return nothing;
|
||||
const overrides = this._settings?.member_display || {};
|
||||
return html`
|
||||
<div class="settings-section member-avatars" data-section="member_avatars">
|
||||
<h3>${t("member_avatars", L)}</h3>
|
||||
<p class="section-desc">${t("member_avatars_hint", L)}</p>
|
||||
${this._users.map((u) => {
|
||||
const person = personOf(u)!;
|
||||
const override = overrides[u.id] || {};
|
||||
return html`
|
||||
<div class="member-avatar-row">
|
||||
${renderPersonAvatar(person)}
|
||||
<span class="member-avatar-name">${u.name}</span>
|
||||
<input class="member-initials" type="text" maxlength="3" autocomplete="off"
|
||||
aria-label="${t("member_initials", L)}"
|
||||
placeholder="${person.initials}"
|
||||
.value=${override.initials ?? ""}
|
||||
@change=${(e: Event) => this._setMemberAvatar(u.id, { initials: (e.target as HTMLInputElement).value })} />
|
||||
<span class="member-palette" role="radiogroup" aria-label="${t("member_color", L)}">
|
||||
${AVATAR_PALETTE.map((color) => html`
|
||||
<button type="button" class="member-swatch${person.color === color ? " selected" : ""}"
|
||||
style="--person-color: ${color}" title=${color} aria-label=${color}
|
||||
@click=${() => this._setMemberAvatar(u.id, { color })}></button>`)}
|
||||
</span>
|
||||
${override.initials || override.color
|
||||
? html`<button class="ha-button secondary member-reset" @click=${() => this._setMemberAvatar(u.id, null)}>${t("member_avatar_reset", L)}</button>`
|
||||
: nothing}
|
||||
</div>`;
|
||||
})}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/** Merge one member's override into the map and save; null resets the member. */
|
||||
private async _setMemberAvatar(userId: string, patch: { initials?: string; color?: string } | null): Promise<void> {
|
||||
const current = { ...(this._settings?.member_display || {}) };
|
||||
if (patch === null) {
|
||||
delete current[userId];
|
||||
} else {
|
||||
const next = { ...(current[userId] || {}), ...patch };
|
||||
if ("initials" in patch && !(patch.initials || "").trim()) delete next.initials;
|
||||
if (Object.keys(next).length) current[userId] = next;
|
||||
else delete current[userId];
|
||||
}
|
||||
await this._updateSetting("member_display", current);
|
||||
// The avatars come back resolved through users/list — refresh the cache.
|
||||
try {
|
||||
this._users = (await this._userService?.getUsers(true)) ?? this._users;
|
||||
} catch {
|
||||
/* keep the stale list; the next open reloads it */
|
||||
}
|
||||
}
|
||||
|
||||
private async _updateSetting(key: string, value: unknown): Promise<void> {
|
||||
try {
|
||||
const result = await this.hass.connection.sendMessagePromise({
|
||||
@@ -293,6 +365,9 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
settings: { [key]: value },
|
||||
});
|
||||
this._settings = result as SettingsResponse;
|
||||
// The card / Lovelace dialogs read their copy through a page-wide
|
||||
// cache — drop it so they follow the change without a reload.
|
||||
invalidateSettingsCache();
|
||||
this._showToast(t("settings_saved", this._lang));
|
||||
this.dispatchEvent(new CustomEvent("settings-changed"));
|
||||
} catch {
|
||||
@@ -329,6 +404,23 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
setTimeout(() => { this._toast = ""; }, 3000);
|
||||
}
|
||||
|
||||
/** Bounded integer settings: an out-of-range entry used to be dropped
|
||||
* silently while the field kept showing it (bug review 2026-09-04). Now
|
||||
* it is named in a toast and the field snaps back to the stored value;
|
||||
* a server reject snaps back through `live()` like the selects. */
|
||||
private _onBoundedIntChange(e: Event, key: string, min: number, max: number, current: number): void {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const v = parseInt(input.value, 10);
|
||||
if (Number.isInteger(v) && v >= min && v <= max) {
|
||||
void this._updateSetting(key, v);
|
||||
return;
|
||||
}
|
||||
this._showToast(
|
||||
t("settings_value_out_of_range", this._lang).replace("{min}", String(min)).replace("{max}", String(max)),
|
||||
);
|
||||
input.value = String(current);
|
||||
}
|
||||
|
||||
private _downloadFile(content: string, filename: string, mime: string): void {
|
||||
// Companion-app safe download (target=_blank + DOM + deferred revoke).
|
||||
downloadTextFile(content, filename, mime);
|
||||
@@ -345,6 +437,7 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
return html`
|
||||
${this._renderFeatures(L)}
|
||||
${this._renderPanelAccess(L)}
|
||||
${this._renderMemberAvatars(L)}
|
||||
${this._renderGeneral(L)}
|
||||
${this._renderObjectsColumns(L)}
|
||||
${this._settings.general.notifications_enabled ? this._renderNotifications(L) : nothing}
|
||||
@@ -632,28 +725,22 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
<h3>${t("settings_general", L)}</h3>
|
||||
<label class="setting-row">
|
||||
<span class="setting-label">${t("settings_default_warning", L)}</span>
|
||||
<input type="number" min="0" max="365" .value=${String(g.default_warning_days)}
|
||||
@change=${(e: Event) => {
|
||||
const v = parseInt((e.target as HTMLInputElement).value, 10);
|
||||
<input type="number" min="0" max="365" .value=${live(String(g.default_warning_days))}
|
||||
@change=${(e: Event) =>
|
||||
// 0 = no warning window (due soon only on the due date) — #145.
|
||||
if (v >= 0 && v <= 365) this._updateSetting("default_warning_days", v);
|
||||
}} />
|
||||
this._onBoundedIntChange(e, "default_warning_days", 0, 365, g.default_warning_days)} />
|
||||
</label>
|
||||
<label class="setting-row">
|
||||
<span class="setting-label">${t("settings_consumable_threshold", L)}</span>
|
||||
<input type="number" min="1" max="90" .value=${String(g.default_consumable_threshold ?? 10)}
|
||||
@change=${(e: Event) => {
|
||||
const v = parseInt((e.target as HTMLInputElement).value, 10);
|
||||
if (v >= 1 && v <= 90) this._updateSetting("default_consumable_threshold", v);
|
||||
}} />
|
||||
<input type="number" min="1" max="90" .value=${live(String(g.default_consumable_threshold ?? 10))}
|
||||
@change=${(e: Event) =>
|
||||
this._onBoundedIntChange(e, "default_consumable_threshold", 1, 90, g.default_consumable_threshold ?? 10)} />
|
||||
</label>
|
||||
<label class="setting-row">
|
||||
<span class="setting-label">${t("settings_battery_low_percent", L)}</span>
|
||||
<input type="number" min="1" max="90" .value=${String(g.battery_low_percent ?? 20)}
|
||||
@change=${(e: Event) => {
|
||||
const v = parseInt((e.target as HTMLInputElement).value, 10);
|
||||
if (v >= 1 && v <= 90) this._updateSetting("battery_low_percent", v);
|
||||
}} />
|
||||
<input type="number" min="1" max="90" .value=${live(String(g.battery_low_percent ?? 20))}
|
||||
@change=${(e: Event) =>
|
||||
this._onBoundedIntChange(e, "battery_low_percent", 1, 90, g.battery_low_percent ?? 20)} />
|
||||
</label>
|
||||
${this._renderBatteryNotesHint(L)}
|
||||
<div class="setting-hint">${t("settings_thresholds_hint", L)}</div>
|
||||
@@ -884,6 +971,25 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
</label>
|
||||
<div class="setting-hint">${t("settings_notify_scope_hint", L)}</div>
|
||||
|
||||
<div class="notify-rule">
|
||||
<h4 style="margin: 16px 0 8px; font-size: 14px;">${t("settings_notify_rule", L)}</h4>
|
||||
<div class="setting-hint">${t("settings_notify_rule_hint", L)}</div>
|
||||
<label class="setting-row">
|
||||
<span class="setting-label">${t("settings_notify_event_only", L)}</span>
|
||||
<input type="checkbox" .checked=${!!n.event_only}
|
||||
@change=${(e: Event) => this._updateSetting("notify_event_only", (e.target as HTMLInputElement).checked)} />
|
||||
</label>
|
||||
<div class="setting-hint">${t("settings_notify_event_only_hint", L)}</div>
|
||||
<label class="setting-row setting-row-block">
|
||||
<span class="setting-label">${t("settings_notify_extra_data", L)}</span>
|
||||
<textarea class="import-area notify-extra" .value=${live(n.extra_data || "")} maxlength="2000" spellcheck="false"
|
||||
placeholder=${'{"category": "maintenance", "critical": {{ priority == "high" }}, "navigate_to": "{{ url }}"}'}
|
||||
@change=${(e: Event) => this._updateSetting("notify_extra_data", (e.target as HTMLTextAreaElement).value)}
|
||||
></textarea>
|
||||
</label>
|
||||
<div class="setting-hint">${t("settings_notify_extra_data_hint", L)}</div>
|
||||
</div>
|
||||
|
||||
<h4 style="margin: 16px 0 8px; font-size: 14px;">${t("settings_actions", L)}</h4>
|
||||
<label class="setting-row">
|
||||
<span class="setting-label">${t("settings_action_complete", L)}</span>
|
||||
@@ -1128,7 +1234,7 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
</div>
|
||||
<div class="vac-preview-actions">
|
||||
<button @click=${() => this._previewActionComplete(row)}>${t("qr_action_complete", L)}</button>
|
||||
${row.kind === "time_based"
|
||||
${row.kind === "time_based" && row.allow_skip !== false
|
||||
? html`<button @click=${() => this._previewActionSkip(row)}>${t("qr_action_skip", L)}</button>`
|
||||
: nothing}
|
||||
<button class=${isExempt ? "vac-notify-on" : ""}
|
||||
@@ -1761,7 +1867,7 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
|
||||
// --- Styles ---
|
||||
|
||||
static styles = css`
|
||||
static styles = [personStyles, css`
|
||||
.bn-note {
|
||||
display: flex; align-items: flex-start; gap: 10px;
|
||||
margin: 6px 0 10px; padding: 10px 12px; border-radius: 8px;
|
||||
@@ -1836,6 +1942,21 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
.notify-person-target.muted {
|
||||
font-style: italic;
|
||||
}
|
||||
/* #169 follow-up: member avatar editor */
|
||||
.member-avatar-row { display: flex; align-items: center; gap: 10px; padding: 6px 0 0; flex-wrap: wrap; }
|
||||
.member-avatar-name { font-weight: 500; min-width: 120px; }
|
||||
.member-initials {
|
||||
width: 4.5em; padding: 4px 6px; text-align: center; text-transform: uppercase;
|
||||
border: 1px solid var(--divider-color, #e0e0e0); border-radius: 6px;
|
||||
background: var(--card-background-color); color: var(--primary-text-color); font: inherit;
|
||||
}
|
||||
.member-palette { display: inline-flex; gap: 4px; flex-wrap: wrap; }
|
||||
.member-swatch {
|
||||
width: 20px; height: 20px; border-radius: 50%; border: 2px solid transparent; padding: 0;
|
||||
background: var(--person-color); cursor: pointer;
|
||||
}
|
||||
.member-swatch.selected { border-color: var(--primary-text-color); box-shadow: 0 0 0 2px var(--card-background-color) inset; }
|
||||
.member-reset { --ha-button-font-size: 12px; }
|
||||
/* v2.27: template gallery clustered by category */
|
||||
.tpl-group { margin-top: 14px; }
|
||||
.tpl-group-head {
|
||||
@@ -1927,6 +2048,8 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
|
||||
.import-section { margin-top: 16px; }
|
||||
|
||||
.setting-row-block { flex-direction: column; align-items: stretch; gap: 6px; }
|
||||
.notify-extra { min-height: 72px; font-family: var(--code-font-family, monospace); font-size: 12.5px; }
|
||||
.import-area {
|
||||
width: 100%;
|
||||
min-height: 120px;
|
||||
@@ -2259,7 +2382,7 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
.qr-print-cell .qr-svg { max-width: 48mm; }
|
||||
.qr-label { font-size: 9pt; }
|
||||
}
|
||||
`;
|
||||
`];
|
||||
}
|
||||
|
||||
customElements.define("maintenance-settings-view", MaintenanceSettingsView);
|
||||
|
||||
+17
@@ -23,6 +23,8 @@ interface StorageSummary {
|
||||
link_count: number;
|
||||
document_count: number;
|
||||
by_object: Record<string, { bytes: number; files: number; links: number }>;
|
||||
/** #171: how much of the library the full-text search can see. */
|
||||
search_index?: { total: number; indexed: number; no_text: number; unsupported: number; pending: number };
|
||||
}
|
||||
|
||||
interface PanelObject {
|
||||
@@ -221,6 +223,16 @@ export class MaintenanceStorageSectionCard extends LitElement {
|
||||
: nothing}
|
||||
</div>
|
||||
|
||||
${s.search_index && s.search_index.total > 0
|
||||
? html`<div class="index-status">
|
||||
<ha-icon icon="mdi:text-search"></ha-icon>
|
||||
${t("search_index_status", L)
|
||||
.replace("{indexed}", String(s.search_index.indexed))
|
||||
.replace("{total}", String(s.search_index.total))
|
||||
.replace("{no_text}", String(s.search_index.no_text + s.search_index.unsupported))
|
||||
.replace("{pending}", String(s.search_index.pending))}
|
||||
</div>`
|
||||
: nothing}
|
||||
<div class="doc-search">
|
||||
<ha-icon icon="mdi:magnify"></ha-icon>
|
||||
<input
|
||||
@@ -284,6 +296,11 @@ export class MaintenanceStorageSectionCard extends LitElement {
|
||||
static styles = css`
|
||||
ha-card { margin-top: 16px; }
|
||||
.card-content { padding: 16px; }
|
||||
.index-status {
|
||||
display: flex; align-items: center; gap: 6px; margin: 10px 0 0; font-size: 12.5px;
|
||||
color: var(--secondary-text-color, #888);
|
||||
}
|
||||
.index-status ha-icon { --mdc-icon-size: 18px; }
|
||||
.doc-search {
|
||||
display: flex; align-items: center; gap: 6px; margin: 10px 0 4px;
|
||||
padding: 2px 10px; border-radius: 8px;
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
import { LitElement, html, css, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import type { AdaptiveConfig, HomeAssistant, MaintenanceTask, TaskPartLink, TriggerConfig, HAUser } from "../types";
|
||||
import { formatDate, t, weekdayName, monthName, langOf } from "../styles";
|
||||
import type { AdaptiveConfig, HomeAssistant, MaintenanceTask, ReadingSlot, TaskPartLink, TriggerConfig, HAUser } from "../types";
|
||||
import { formatDate, formatNumber, t, weekdayName, monthName, langOf } from "../styles";
|
||||
import { UserService } from "../user-service";
|
||||
import { partLinkKey } from "../helpers/shared-parts";
|
||||
import { MAX_READING_SLOTS, cleanReadingSlots, duplicateReadingSlotIds, newReadingSlotId } from "../helpers/reading-slots";
|
||||
import {
|
||||
ENVIRONMENTAL_PICKER_DEVICE_CLASSES,
|
||||
ENVIRONMENTAL_PICKER_DOMAINS,
|
||||
@@ -21,6 +22,9 @@ const MAINTENANCE_TYPE_KEYS = ["cleaning", "inspection", "replacement", "calibra
|
||||
const PRIORITY_KEYS = ["low", "normal", "high"];
|
||||
const SCHEDULE_TYPE_KEYS = ["time_based", "weekdays", "nth_weekday", "day_of_month", "sensor_based", "one_time", "manual"];
|
||||
const CALENDAR_KINDS = ["weekdays", "nth_weekday", "day_of_month"];
|
||||
// #168: kinds that produce a due DATE and therefore take a time of day
|
||||
// (mirrors SCHEDULE_TIME_KINDS in config_flow_helpers.py).
|
||||
const SCHEDULE_TIME_KINDS = ["time_based", "one_time", ...CALENDAR_KINDS];
|
||||
const TRIGGER_TYPE_KEYS = ["threshold", "counter", "state_change", "runtime"];
|
||||
// The type selector additionally offers "compound" (a group of conditions
|
||||
// joined by AND/OR); its per-condition sub-type is limited to the flat kinds.
|
||||
@@ -249,6 +253,8 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
@state() private _allowSkip = true;
|
||||
// v2.20 (#83): unit for `reading`-type tasks ("kWh", "m³", ...)
|
||||
@state() private _readingUnit = "";
|
||||
// #161 phase 2: named reading slots — several values per completion.
|
||||
@state() private _readings: ReadingSlot[] = [];
|
||||
/** The picked links, keyed by `partLinkKey` — the (entry_id, part_id) pair,
|
||||
* since the same part id can exist on two objects (battery fleet). */
|
||||
@state() private _consumesParts: Record<string, TaskPartLink> = {};
|
||||
@@ -285,6 +291,9 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
|
||||
// Schedule time (HH:MM, advanced feature)
|
||||
@state() private _scheduleTime = "";
|
||||
// The picker only shows once this is ticked (#168 follow-up) — most tasks
|
||||
// are due "on the day", so the form stays lean. Unticking clears the time.
|
||||
@state() private _scheduleTimeOn = false;
|
||||
|
||||
// v1.3.0: on_complete_action (gated by completionActionsEnabled)
|
||||
// v1.3.1: _actionData is the parsed object (was: _actionDataJson string)
|
||||
@@ -404,6 +413,7 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
this._requireTagScan = !!task.require_tag_scan;
|
||||
this._allowSkip = task.allow_skip !== false;
|
||||
this._readingUnit = task.reading_unit || "";
|
||||
this._readings = (task.readings || []).map((s) => ({ ...s }));
|
||||
// Whole link, entry_id included — hydrating only part_id would turn every
|
||||
// shared-pool link into an own-part link on the next save (#111).
|
||||
this._consumesParts = Object.fromEntries(
|
||||
@@ -443,6 +453,7 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
this._phaseSeq = [...(task.phase_sequence || [])];
|
||||
this._requiredCompletion = [...(task.required_completion_fields || [])];
|
||||
this._scheduleTime = task.schedule_time || "";
|
||||
this._scheduleTimeOn = !!task.schedule_time;
|
||||
|
||||
// v1.3.0: hydrate on_complete_action + quick_complete_defaults
|
||||
const oca = task.on_complete_action;
|
||||
@@ -557,6 +568,7 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
this._requireTagScan = false;
|
||||
this._allowSkip = true;
|
||||
this._readingUnit = "";
|
||||
this._readings = [];
|
||||
this._consumesParts = {};
|
||||
this._responsibleUserId = null;
|
||||
this._assigneePool = [];
|
||||
@@ -566,6 +578,7 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
this._phaseSeq = [];
|
||||
this._requiredCompletion = [];
|
||||
this._scheduleTime = "";
|
||||
this._scheduleTimeOn = false;
|
||||
this._environmentalEntity = "";
|
||||
this._environmentalAttribute = "";
|
||||
this._environmentalInitial = "";
|
||||
@@ -1052,6 +1065,47 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
this._requiredCompletion = [...next];
|
||||
}
|
||||
|
||||
// ── Reading slots (#161 phase 2) ─────────────────────────────────────
|
||||
|
||||
private _patchReading(id: string, patch: Partial<ReadingSlot>): void {
|
||||
this._readings = this._readings.map((s) => (s.id === id ? { ...s, ...patch } : s));
|
||||
}
|
||||
|
||||
private _renderReadingsEditor(L: string) {
|
||||
const dupes = duplicateReadingSlotIds(this._readings);
|
||||
return html`
|
||||
<div class="readings-editor">
|
||||
<div class="field-label">${t("readings_section", L)}</div>
|
||||
<div class="field-help">${t("readings_hint", L)}</div>
|
||||
${this._readings.map((s) => html`
|
||||
${dupes.has(s.id) ? html`<div class="field-help reading-dup">${t("reading_duplicate_name", L)}</div>` : nothing}
|
||||
<div class="reading-row">
|
||||
<ms-textfield
|
||||
class="reading-name"
|
||||
label="${t("reading_name_label", L)}"
|
||||
.value=${s.name}
|
||||
@input=${(e: Event) => this._patchReading(s.id, { name: (e.target as HTMLInputElement).value })}
|
||||
></ms-textfield>
|
||||
<ms-textfield
|
||||
class="reading-unit"
|
||||
label="${t("reading_unit_short", L)}"
|
||||
.value=${s.unit || ""}
|
||||
@input=${(e: Event) => this._patchReading(s.id, { unit: (e.target as HTMLInputElement).value })}
|
||||
></ms-textfield>
|
||||
<mwc-icon-button class="phase-remove reading-remove" @click=${() => (this._readings = this._readings.filter((x) => x.id !== s.id))}>
|
||||
<ha-icon icon="mdi:delete-outline"></ha-icon>
|
||||
</mwc-icon-button>
|
||||
</div>
|
||||
`)}
|
||||
${this._readings.length < MAX_READING_SLOTS ? html`
|
||||
<ha-button appearance="plain" class="reading-add"
|
||||
@click=${() => (this._readings = [...this._readings, { id: newReadingSlotId(), name: "", unit: this._readings.length ? this._readings[this._readings.length - 1].unit : this._readingUnit }])}>
|
||||
<ha-icon icon="mdi:plus"></ha-icon> ${t("reading_add", L)}
|
||||
</ha-button>` : nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Task phases (#139) ───────────────────────────────────────────────
|
||||
|
||||
private _phaseSlug(name: string): string {
|
||||
@@ -1265,6 +1319,10 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
data.require_tag_scan = this._requireTagScan;
|
||||
data.allow_skip = this._allowSkip;
|
||||
data.reading_unit = this._readingUnit.trim() || null;
|
||||
// #161 phase 2: always sent — [] clears the slots (single-value task).
|
||||
// Sent as hydrated even when the editor is hidden (type != reading), so
|
||||
// a no-op edit never wipes stored slots (the #42/#50 wipe class).
|
||||
data.readings = cleanReadingSlots(this._readings);
|
||||
// Task phases (#139): always sent — null clears a removed cycle.
|
||||
{
|
||||
const defs: Record<string, unknown> = {};
|
||||
@@ -1388,9 +1446,11 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
data.trigger_config = null;
|
||||
}
|
||||
|
||||
// Schedule time only sent when feature is enabled — empty string clears.
|
||||
if (this.scheduleTimeEnabled && this._scheduleType === "time_based") {
|
||||
const t = this._scheduleTime.trim();
|
||||
// Schedule time only sent when feature is enabled — null clears.
|
||||
// Every date-driven kind takes one (#168), not only the interval; an
|
||||
// unticked checkbox (or a ticked one with an empty picker) clears it.
|
||||
if (this.scheduleTimeEnabled && SCHEDULE_TIME_KINDS.includes(this._scheduleType)) {
|
||||
const t = this._scheduleTimeOn ? this._scheduleTime.trim() : "";
|
||||
data.schedule_time = /^([01]\d|2[0-3]):[0-5]\d$/.test(t) ? t : null;
|
||||
}
|
||||
|
||||
@@ -2103,7 +2163,7 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
private _renderSchedulePreview() {
|
||||
if (this._schedulePreview.length === 0) return nothing;
|
||||
const L = this._lang;
|
||||
const time = this.scheduleTimeEnabled && this._scheduleTime ? ` ${this._scheduleTime}` : "";
|
||||
const time = this.scheduleTimeEnabled && this._scheduleTimeOn && this._scheduleTime ? ` ${this._scheduleTime}` : "";
|
||||
const chips = this._schedulePreview
|
||||
.map((iso, i) => {
|
||||
const js = new Date(`${iso}T12:00:00`).getDay(); // 0=Sun
|
||||
@@ -2331,7 +2391,9 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
: st.state;
|
||||
const num = typeof raw === "number" ? raw : parseFloat(String(raw));
|
||||
const hasNum = raw !== "unknown" && raw !== "unavailable" && raw != null && !isNaN(num);
|
||||
const fmt = (v: number) => (Number.isInteger(v) ? String(v) : String(Math.round(v * 10) / 10));
|
||||
// Profile number format like every other figure (#163) — the hint used
|
||||
// to print "1.2" next to a "1,2 bar" entity card (bug review 2026-09-04).
|
||||
const fmt = (v: number) => formatNumber(v, L, { maximumFractionDigits: 1 });
|
||||
|
||||
const parts: string[] = [];
|
||||
if (this._triggerType === "threshold") {
|
||||
@@ -2487,6 +2549,9 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
@input=${(e: Event) => (this._triggerTargetChanges = (e.target as HTMLInputElement).value)}
|
||||
></ms-textfield>
|
||||
<div class="field-help">${t("target_changes_help", L)}</div>
|
||||
${(this._triggerTargetChanges || "1") === "1" && (this._triggerFromState || this._triggerToState)
|
||||
? html`<div class="field-help">${t("state_latch_help", L)}</div>`
|
||||
: nothing}
|
||||
<ms-textfield
|
||||
label="${t("for_at_least_minutes", L)}"
|
||||
type="number"
|
||||
@@ -2579,6 +2644,7 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
@input=${(e: Event) => (this._readingUnit = (e.target as HTMLInputElement).value)}
|
||||
></ms-textfield>
|
||||
<div class="field-help">${t("reading_unit_help", L)}</div>
|
||||
${this._renderReadingsEditor(L)}
|
||||
`
|
||||
: nothing}
|
||||
${this._partsLoadFailed
|
||||
@@ -2659,18 +2725,6 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
<option value="planned" ?selected=${this._intervalAnchor === "planned"}>${t("anchor_planned", L)}</option>
|
||||
</select>
|
||||
</div>
|
||||
${this.scheduleTimeEnabled ? html`
|
||||
<ms-date-field
|
||||
kind="time"
|
||||
clearable
|
||||
.hass=${this.hass}
|
||||
.lang=${L}
|
||||
label="${t("schedule_time_optional", L)}"
|
||||
.value=${this._scheduleTime}
|
||||
helper="${t("schedule_time_help", L)}"
|
||||
@value-changed=${(e: CustomEvent) => (this._scheduleTime = e.detail.value as string)}
|
||||
></ms-date-field>
|
||||
` : nothing}
|
||||
`
|
||||
: nothing}
|
||||
${this._renderCalendarFields()}
|
||||
@@ -2686,6 +2740,23 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
></ms-date-field>
|
||||
`
|
||||
: nothing}
|
||||
${this.scheduleTimeEnabled && SCHEDULE_TIME_KINDS.includes(this._scheduleType) ? html`
|
||||
<label class="checkbox-row schedule-time-toggle">
|
||||
<input type="checkbox" .checked=${this._scheduleTimeOn}
|
||||
@change=${(e: Event) => (this._scheduleTimeOn = (e.target as HTMLInputElement).checked)} />
|
||||
<span>${t("schedule_time_toggle", L)}</span>
|
||||
</label>
|
||||
${this._scheduleTimeOn ? html`
|
||||
<ms-date-field
|
||||
kind="time"
|
||||
.hass=${this.hass}
|
||||
.lang=${L}
|
||||
.value=${this._scheduleTime}
|
||||
helper="${t("schedule_time_help", L)}"
|
||||
@value-changed=${(e: CustomEvent) => (this._scheduleTime = e.detail.value as string)}
|
||||
></ms-date-field>
|
||||
` : nothing}
|
||||
` : nothing}
|
||||
${this._renderRecurrenceExtras()}
|
||||
${this._renderSchedulePreview()}
|
||||
<ms-textfield
|
||||
@@ -3067,6 +3138,17 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
--mdc-icon-button-size: 36px;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
/* #161 phase 2: reading slots — name wide, unit narrow, trash. */
|
||||
.readings-editor { margin-top: 14px; }
|
||||
.reading-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
.reading-row .reading-name { flex: 2; min-width: 0; }
|
||||
.reading-row .reading-unit { flex: 1; min-width: 0; max-width: 140px; }
|
||||
.reading-dup { color: var(--warning-color, #ff9800); margin-top: 6px; }
|
||||
.phase-seq-label {
|
||||
font-size: 12px;
|
||||
color: var(--secondary-text-color);
|
||||
@@ -3214,6 +3296,10 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.schedule-time-toggle {
|
||||
display: flex;
|
||||
margin: 8px 0 4px;
|
||||
}
|
||||
.pool-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -18,6 +18,7 @@ import { downloadSignedDocument, openSignedDocument } from "../helpers/document-
|
||||
import { isSafeHttpUrl } from "../helpers/url";
|
||||
import { formatBytes } from "../helpers/format-bytes";
|
||||
import { docDisplayName, CATEGORIES, CATEGORY_ICONS } from "../helpers/document-categories";
|
||||
import { DOC_FILTER_MIN, filterDocuments } from "../helpers/document-filter";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
interface Doc {
|
||||
@@ -47,6 +48,8 @@ export class MaintenanceTaskDocuments extends LitElement {
|
||||
@state() private _busy = false;
|
||||
@state() private _error = "";
|
||||
@state() private _attachId = "";
|
||||
// Local filter (#171) — shown once the linked list is long enough to need one.
|
||||
@state() private _filter = "";
|
||||
|
||||
private _loadedKey = "";
|
||||
private _localeReady = false;
|
||||
@@ -199,9 +202,21 @@ export class MaintenanceTaskDocuments extends LitElement {
|
||||
<div class="task-docs">
|
||||
<h3><ha-icon icon="mdi:paperclip"></ha-icon> ${t("documents", L)} (${linked.length})</h3>
|
||||
${this._error ? html`<div class="tdoc-error">${this._error}</div>` : nothing}
|
||||
${linked.length >= DOC_FILTER_MIN
|
||||
? html`<div class="doc-filter">
|
||||
<ha-icon icon="mdi:magnify"></ha-icon>
|
||||
<input type="search" aria-label=${t("doc_search", L)} placeholder=${t("doc_search", L)}
|
||||
.value=${this._filter} @input=${(e: Event) => (this._filter = (e.target as HTMLInputElement).value)} />
|
||||
</div>`
|
||||
: nothing}
|
||||
${linked.length === 0
|
||||
? html`<div class="tdoc-empty">${t(this.partId ? "doc_part_none" : "doc_task_none", L)}</div>`
|
||||
: html`<div class="tdoc-list">${linked.map((d) => this._renderRow(d, L))}</div>`}
|
||||
: (() => {
|
||||
const shown = filterDocuments(linked, this._filter);
|
||||
return shown.length === 0
|
||||
? html`<div class="tdoc-empty">${t("doc_search_none", L)}</div>`
|
||||
: html`<div class="tdoc-list">${shown.map((d) => this._renderRow(d, L))}</div>`;
|
||||
})()}
|
||||
${this.canWrite && available.length
|
||||
? html`<div class="tdoc-attach">
|
||||
<select
|
||||
@@ -295,6 +310,14 @@ export class MaintenanceTaskDocuments extends LitElement {
|
||||
.tdoc-empty { color: var(--secondary-text-color, #888); font-size: 13px; padding: 2px 0 8px; }
|
||||
.tdoc-error { color: var(--error-color, #f44336); font-size: 13px; margin: 4px 0; }
|
||||
.tdoc-list { display: flex; flex-direction: column; gap: 4px; }
|
||||
.doc-filter { display: flex; align-items: center; gap: 6px; margin: 4px 0 8px; }
|
||||
.doc-filter ha-icon { --mdc-icon-size: 18px; color: var(--secondary-text-color, #888); }
|
||||
.doc-filter input {
|
||||
flex: 1; min-width: 0; font: inherit; font-size: 13px; padding: 6px 8px; border-radius: 6px;
|
||||
border: 1px solid var(--divider-color); background: var(--card-background-color, #fff);
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
.doc-filter input:focus { outline: none; border-color: var(--primary-color); }
|
||||
.tdoc-row {
|
||||
display: flex; align-items: center; gap: 10px; padding: 6px 10px;
|
||||
border: 1px solid var(--divider-color); border-radius: 8px;
|
||||
|
||||
+48
@@ -19,6 +19,9 @@ import { describeWsError } from "../ws-errors";
|
||||
import { isoDateLocal } from "../helpers/calendar-bucket";
|
||||
import { buildCompleteDialogArgs } from "../helpers/complete-dialog-args";
|
||||
import { phaseLabel } from "../helpers/phases";
|
||||
import { historyPhotoIds } from "../helpers/history-photos";
|
||||
import { entryReadingValues, readingSlotDelta } from "../helpers/reading-slots";
|
||||
import "./history-photo";
|
||||
import { renderWeibullSection } from "../renderers/weibull";
|
||||
import { renderPredictionSection } from "../renderers/prediction";
|
||||
import { renderRecommendationBars } from "../renderers/recommendation";
|
||||
@@ -358,6 +361,12 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
|
||||
duration: entry.duration ?? null,
|
||||
completed_by: entry.completed_by ?? null,
|
||||
used_parts: entry.used_parts ?? null,
|
||||
photo_doc_ids: historyPhotoIds(entry),
|
||||
reading_value: entry.reading_value ?? null,
|
||||
reading_values: entryReadingValues(entry),
|
||||
readings: this._task?.readings ?? [],
|
||||
task_type: this._task?.type ?? null,
|
||||
reading_unit: this._task?.reading_unit ?? null,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -439,6 +448,30 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
/** #161: the entry's readings — per-slot lines with the delta against the
|
||||
* previous entry carrying the slot, or the single value of a scalar task.
|
||||
* Parity with the panel timeline for the Lovelace / strategy path. */
|
||||
private _renderHistoryReadings(entry: HistoryEntry, history: HistoryEntry[], L: string) {
|
||||
const values = entryReadingValues(entry);
|
||||
const fmt = (n: number) => formatNumber(n, L, { maximumFractionDigits: 3 });
|
||||
if (values.length > 0) {
|
||||
return html`<div class="history-readings">
|
||||
${values.map((v) => {
|
||||
const d = readingSlotDelta(history, entry, v.id);
|
||||
return html`<span class="history-reading"><span class="history-reading-name">${v.name}</span>
|
||||
<span class="history-reading-value">${fmt(v.value)}${v.unit ? ` ${v.unit}` : ""}${d == null ? "" : ` (${d >= 0 ? "+" : ""}${fmt(d)})`}</span></span>`;
|
||||
})}
|
||||
</div>`;
|
||||
}
|
||||
if (entry.reading_value != null) {
|
||||
const unit = this._task?.reading_unit ? ` ${this._task.reading_unit}` : "";
|
||||
return html`<div class="history-readings"><span class="history-reading">
|
||||
<span class="history-reading-name">${t("reading_label", L)}</span>
|
||||
<span class="history-reading-value">${fmt(entry.reading_value)}${unit}</span></span></div>`;
|
||||
}
|
||||
return nothing;
|
||||
}
|
||||
|
||||
/** Read-only details panel: stats + history. Shown when the user clicks
|
||||
* "Show details" in the dialog. Edit-buttons on history entries open the
|
||||
* existing history-edit dialog (which lives in the same dialog-mount). */
|
||||
@@ -500,6 +533,15 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
|
||||
${entry.notes
|
||||
? html`<div class="history-notes">${entry.notes}</div>`
|
||||
: nothing}
|
||||
${this._renderHistoryReadings(entry, history, L)}
|
||||
${(() => {
|
||||
const photos = historyPhotoIds(entry);
|
||||
return photos.length
|
||||
? html`<div class="history-photos">
|
||||
${photos.map((docId) => html`<maintenance-history-photo .hass=${this.hass} .docId=${docId}></maintenance-history-photo>`)}
|
||||
</div>`
|
||||
: nothing;
|
||||
})()}
|
||||
${entry.cost != null || entry.duration != null
|
||||
? html`<div class="history-meta">
|
||||
${entry.cost != null ? html`<span>💰 ${formatNumber(entry.cost, L, 2)}</span>` : nothing}
|
||||
@@ -830,6 +872,12 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
|
||||
.history-edit ha-icon { --mdc-icon-size: 14px; }
|
||||
.history-notes { margin-top: 4px; color: var(--primary-text-color); }
|
||||
.history-meta { display: flex; gap: 12px; margin-top: 4px; color: var(--secondary-text-color); font-size: 11px; }
|
||||
/* #161: readings + photos on the entry (panel-timeline parity) */
|
||||
.history-readings { display: flex; flex-wrap: wrap; gap: 2px 16px; margin-top: 4px; font-size: 12px; }
|
||||
.history-reading { display: inline-flex; gap: 6px; }
|
||||
.history-reading-name { color: var(--secondary-text-color); }
|
||||
.history-reading-value { font-variant-numeric: tabular-nums; }
|
||||
.history-photos { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 4px; }
|
||||
.history-more { padding: 8px; text-align: center; font-size: 12px; color: var(--secondary-text-color); font-style: italic; }
|
||||
|
||||
/* Adaptive section — wraps the panel renderers (which assume sharedStyles
|
||||
|
||||
Reference in New Issue
Block a user