217 files
This commit is contained in:
+48
-32
@@ -11,18 +11,14 @@ import { property, state } from "lit/decorators.js";
|
||||
import { t, ensureLocale, DEFAULT_CURRENCY_SYMBOL } from "../styles";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { sectionCardSharedStyles } from "./section-card-shared-styles";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
interface BudgetStatus {
|
||||
monthly_budget?: number;
|
||||
monthly_spent?: number;
|
||||
yearly_budget?: number;
|
||||
yearly_spent?: number;
|
||||
currency_symbol?: string;
|
||||
}
|
||||
import type { BudgetStatus, HomeAssistant } from "../types";
|
||||
|
||||
interface CardConfig { type: string; title?: string; }
|
||||
|
||||
/** Backend default for budget_alert_threshold (const.py / settings_registry).
|
||||
* Only reached if an older core omits the field from budget_status. */
|
||||
const DEFAULT_ALERT_THRESHOLD_PCT = 80;
|
||||
|
||||
export class MaintenanceBudgetSectionCard extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@state() private _config: CardConfig = { type: "" };
|
||||
@@ -109,10 +105,21 @@ export class MaintenanceBudgetSectionCard extends LitElement {
|
||||
return html`<ha-card><div class="loading">${t("loading", L) || "Loading…"}</div></ha-card>`;
|
||||
}
|
||||
const sym = s.currency_symbol || DEFAULT_CURRENCY_SYMBOL;
|
||||
const mPct = s.monthly_budget ? Math.min(100, ((s.monthly_spent || 0) / s.monthly_budget) * 100) : 0;
|
||||
const yPct = s.yearly_budget ? Math.min(100, ((s.yearly_spent || 0) / s.yearly_budget) * 100) : 0;
|
||||
const mWarn = mPct >= 100 ? "danger" : mPct >= 80 ? "warning" : "ok";
|
||||
const yWarn = yPct >= 100 ? "danger" : yPct >= 80 ? "warning" : "ok";
|
||||
// The amber step is the CONFIGURED budget_alert_threshold, not a literal 80
|
||||
// — same rule the panel's budget bar uses (maintenance-panel._renderBudgetBar).
|
||||
const threshold = s.alert_threshold_pct ?? DEFAULT_ALERT_THRESHOLD_PCT;
|
||||
const tracks = [
|
||||
{
|
||||
label: t("budget_monthly", L) || "Monthly",
|
||||
spent: s.monthly_spent || 0,
|
||||
budget: s.monthly_budget || 0,
|
||||
},
|
||||
{
|
||||
label: t("budget_yearly", L) || "Yearly",
|
||||
spent: s.yearly_spent || 0,
|
||||
budget: s.yearly_budget || 0,
|
||||
},
|
||||
];
|
||||
|
||||
return html`
|
||||
<ha-card>
|
||||
@@ -127,25 +134,34 @@ export class MaintenanceBudgetSectionCard extends LitElement {
|
||||
|
||||
${this._error ? html`<div class="error">${this._error}</div>` : nothing}
|
||||
|
||||
<div class="track">
|
||||
<div class="track-label-row">
|
||||
<label>${t("budget_monthly", L) || "Monthly"}</label>
|
||||
<span class="track-numbers ${mWarn}">
|
||||
${(s.monthly_spent || 0).toFixed(0)} / ${(s.monthly_budget || 0).toFixed(0)} ${sym}
|
||||
</span>
|
||||
</div>
|
||||
<div class="bar"><div class="bar-fill ${mWarn}" style="width:${mPct}%"></div></div>
|
||||
</div>
|
||||
|
||||
<div class="track">
|
||||
<div class="track-label-row">
|
||||
<label>${t("budget_yearly", L) || "Yearly"}</label>
|
||||
<span class="track-numbers ${yWarn}">
|
||||
${(s.yearly_spent || 0).toFixed(0)} / ${(s.yearly_budget || 0).toFixed(0)} ${sym}
|
||||
</span>
|
||||
</div>
|
||||
<div class="bar"><div class="bar-fill ${yWarn}" style="width:${yPct}%"></div></div>
|
||||
</div>
|
||||
${tracks.map((track) => {
|
||||
// #104: budget tracking without a maximum — a bar needs a
|
||||
// denominator, so show the plain spent total instead of "9 / 0 €"
|
||||
// over an always-empty bar. Mirrors the panel's spent-only lines.
|
||||
if (!(track.budget > 0)) {
|
||||
return html`
|
||||
<div class="track spent-only">
|
||||
<div class="track-label-row">
|
||||
<label>${track.label}</label>
|
||||
<span class="track-numbers ok">${track.spent.toFixed(0)} ${sym}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
const pct = Math.min(100, Math.max(0, (track.spent / track.budget) * 100));
|
||||
const warn = pct >= 100 ? "danger" : pct >= threshold ? "warning" : "ok";
|
||||
return html`
|
||||
<div class="track">
|
||||
<div class="track-label-row">
|
||||
<label>${track.label}</label>
|
||||
<span class="track-numbers ${warn}">
|
||||
${track.spent.toFixed(0)} / ${track.budget.toFixed(0)} ${sym}
|
||||
</span>
|
||||
</div>
|
||||
<div class="bar"><div class="bar-fill ${warn}" style="width:${pct}%"></div></div>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
|
||||
${this._isAdmin
|
||||
? html`
|
||||
|
||||
+86
-23
@@ -2,9 +2,11 @@
|
||||
|
||||
import { LitElement, html, css, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { HomeAssistant, TaskPartLink } from "../types";
|
||||
import { t, nativeFieldStyles } from "../styles";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { partLinkKey, type LinkedPart } from "../helpers/shared-parts";
|
||||
import { REQUIRED_COMPLETION_LABELS } from "./required-completion-labels";
|
||||
|
||||
export class MaintenanceCompleteDialog extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -19,12 +21,20 @@ export class MaintenanceCompleteDialog extends LitElement {
|
||||
@property() public readingUnit = "";
|
||||
/** Buy task (part_ref): default restock quantity — shows an editable qty field. */
|
||||
@property({ attribute: false }) public restockDefault: number | null = null;
|
||||
/** #99: the object's parts — enables the editable "parts used" section. */
|
||||
@property({ attribute: false }) public parts: Array<{ id: string; name: string; unit?: string | null; stock?: number | null }> = [];
|
||||
/** #99: the task's fixed consumes_parts links (prefill for the section). */
|
||||
@property({ attribute: false }) public consumesParts: Array<{ part_id: string; quantity: number }> = [];
|
||||
/** #99: the parts offered on completion — enables the editable "parts used"
|
||||
* section. Built by `partsForCompletion`: the object's own inventory plus
|
||||
* every shared pool this task links to (#111), each tagged with its owner. */
|
||||
@property({ attribute: false }) public parts: LinkedPart[] = [];
|
||||
/** #99: the task's fixed consumes_parts links (prefill for the section).
|
||||
* A link may carry an `entry_id` (#111) and MUST keep it through the edit —
|
||||
* without it the completion would decrement the wrong inventory, or none. */
|
||||
@property({ attribute: false }) public consumesParts: TaskPartLink[] = [];
|
||||
/** "Consumes: 1× HEPA-Filter (Shelf B)" hint lines for consuming tasks. */
|
||||
@property({ type: Array }) public consumesInfo: string[] = [];
|
||||
/** Details this task demands before it counts as done (v2.44). The backend
|
||||
* enforces the same list at every completion surface; blocking Save here
|
||||
* just means the user never has to meet that rejection. */
|
||||
@property({ type: Array }) public requiredFields: string[] = [];
|
||||
@state() private _open = false;
|
||||
@state() private _notes = "";
|
||||
@state() private _cost = "";
|
||||
@@ -38,7 +48,9 @@ export class MaintenanceCompleteDialog extends LitElement {
|
||||
@state() private _photoUploading = false;
|
||||
@state() private _readingValue = "";
|
||||
@state() private _restockQty = "";
|
||||
@state() private _usedParts: Record<string, number> = {};
|
||||
/** Keyed by `partLinkKey` — the (entry_id, part_id) pair — because two
|
||||
* objects can carry the same part id, so part_id alone would merge pools. */
|
||||
@state() private _usedParts: Record<string, TaskPartLink> = {};
|
||||
|
||||
public open(): void {
|
||||
if (this._open) return;
|
||||
@@ -55,8 +67,9 @@ export class MaintenanceCompleteDialog extends LitElement {
|
||||
this._readingValue = "";
|
||||
this._restockQty = this.restockDefault !== null ? String(this.restockDefault) : "";
|
||||
// #99: prefill "parts used" with the task's fixed links — the user can
|
||||
// untick or adjust before completing.
|
||||
this._usedParts = Object.fromEntries(this.consumesParts.map((l) => [l.part_id, l.quantity]));
|
||||
// untick or adjust before completing. The whole link is kept, entry_id
|
||||
// included, so a shared pool survives the edit (#111).
|
||||
this._usedParts = Object.fromEntries(this.consumesParts.map((l) => [partLinkKey(l), { ...l }]));
|
||||
}
|
||||
|
||||
private _toggleCheck(idx: number): void {
|
||||
@@ -149,10 +162,16 @@ export class MaintenanceCompleteDialog extends LitElement {
|
||||
}
|
||||
// #99: with a parts section shown, send the explicit selection — it
|
||||
// replaces the automatic consumes_parts deduction (empty = none used).
|
||||
// entry_id travels only when the pool is somebody else's (#111), so an
|
||||
// own-part payload is byte-identical to what shipped before.
|
||||
if (this.parts.length > 0) {
|
||||
data.used_parts = Object.entries(this._usedParts)
|
||||
.filter(([, qty]) => Number.isFinite(qty) && qty > 0)
|
||||
.map(([part_id, quantity]) => ({ part_id, quantity }));
|
||||
data.used_parts = Object.values(this._usedParts)
|
||||
.filter((l) => Number.isFinite(l.quantity) && l.quantity > 0)
|
||||
.map((l) =>
|
||||
l.entry_id
|
||||
? { part_id: l.part_id, quantity: l.quantity, entry_id: l.entry_id }
|
||||
: { part_id: l.part_id, quantity: l.quantity },
|
||||
);
|
||||
}
|
||||
await this.hass.connection.sendMessagePromise(data);
|
||||
this._open = false;
|
||||
@@ -164,6 +183,28 @@ export class MaintenanceCompleteDialog extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 !== "",
|
||||
// "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
|
||||
// was how a task requiring "user" ended up unclosable: Save stayed
|
||||
// enabled and the backend rejected the completion every time.
|
||||
user: !!this.hass?.user,
|
||||
};
|
||||
return this.requiredFields.filter((f) => !filled[f]);
|
||||
}
|
||||
|
||||
/** Marker appended to a required field's label. */
|
||||
private _req(field: string) {
|
||||
return this.requiredFields.includes(field) ? html`<span class="req-mark" aria-hidden="true">*</span>` : nothing;
|
||||
}
|
||||
|
||||
private _close(): void {
|
||||
this._open = false;
|
||||
}
|
||||
@@ -200,25 +241,36 @@ export class MaintenanceCompleteDialog extends LitElement {
|
||||
? html`<div class="used-parts">
|
||||
<span class="field-label">${t("complete_parts_used", L)}</span>
|
||||
${this.parts.map((pt) => {
|
||||
const qty = this._usedParts[pt.id];
|
||||
const checked = qty !== undefined;
|
||||
const key = partLinkKey({ part_id: pt.id, entry_id: pt.entry_id });
|
||||
const link = this._usedParts[key];
|
||||
const checked = link !== undefined;
|
||||
const base: TaskPartLink = pt.entry_id
|
||||
? { part_id: pt.id, quantity: 1, entry_id: pt.entry_id }
|
||||
: { part_id: pt.id, quantity: 1 };
|
||||
return html`<div class="used-part-row">
|
||||
<label class="used-part-check">
|
||||
<input type="checkbox" .checked=${checked}
|
||||
@change=${(e: Event) => {
|
||||
const next = { ...this._usedParts };
|
||||
if ((e.target as HTMLInputElement).checked) next[pt.id] = next[pt.id] || 1;
|
||||
else delete next[pt.id];
|
||||
if ((e.target as HTMLInputElement).checked) next[key] = next[key] || base;
|
||||
else delete next[key];
|
||||
this._usedParts = next;
|
||||
}} />
|
||||
<span>${pt.name}${pt.stock !== null && pt.stock !== undefined ? ` (${pt.stock}${pt.unit ? " " + pt.unit : ""})` : ""}</span>
|
||||
<span
|
||||
>${pt.name}${pt.owner_name
|
||||
? html`<span class="used-part-owner"> (${pt.owner_name})</span>`
|
||||
: nothing}${pt.stock !== null && pt.stock !== undefined ? ` (${pt.stock}${pt.unit ? " " + pt.unit : ""})` : ""}</span
|
||||
>
|
||||
</label>
|
||||
${checked
|
||||
? html`<input class="used-part-qty" type="number" min="0.01" max="999" step="0.01"
|
||||
.value=${String(qty)}
|
||||
.value=${String(link.quantity)}
|
||||
@input=${(e: Event) => {
|
||||
const v = parseFloat((e.target as HTMLInputElement).value);
|
||||
this._usedParts = { ...this._usedParts, [pt.id]: Number.isFinite(v) && v >= 0.01 ? v : 1 };
|
||||
this._usedParts = {
|
||||
...this._usedParts,
|
||||
[key]: { ...base, quantity: Number.isFinite(v) && v >= 0.01 ? v : 1 },
|
||||
};
|
||||
}} />`
|
||||
: nothing}
|
||||
</div>`;
|
||||
@@ -245,25 +297,25 @@ export class MaintenanceCompleteDialog extends LitElement {
|
||||
only sees the title + Cancel/Complete buttons — the original
|
||||
bug report. Native inputs always render. -->
|
||||
<label class="field">
|
||||
<span class="field-label">${t("notes_optional", L)}</span>
|
||||
<span class="field-label">${t("notes_optional", L)}${this._req("notes")}</span>
|
||||
<input type="text" class="field-input"
|
||||
.value=${this._notes}
|
||||
@input=${(e: Event) => (this._notes = (e.target as HTMLInputElement).value)} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">${t("cost_optional", L)}</span>
|
||||
<span class="field-label">${t("cost_optional", L)}${this._req("cost")}</span>
|
||||
<input type="number" step="0.01" min="0" class="field-input"
|
||||
.value=${this._cost}
|
||||
@input=${(e: Event) => (this._cost = (e.target as HTMLInputElement).value)} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">${t("duration_minutes", L)}</span>
|
||||
<span class="field-label">${t("duration_minutes", L)}${this._req("duration")}</span>
|
||||
<input type="number" step="0.01" min="0" class="field-input"
|
||||
.value=${this._duration}
|
||||
@input=${(e: Event) => (this._duration = (e.target as HTMLInputElement).value)} />
|
||||
</label>
|
||||
<div class="field">
|
||||
<span class="field-label">${t("completion_photo_optional", L)}</span>
|
||||
<span class="field-label">${t("completion_photo_optional", L)}${this._req("photo")}</span>
|
||||
${this._photoPreview
|
||||
? html`
|
||||
<div class="photo-preview">
|
||||
@@ -306,7 +358,10 @@ export class MaintenanceCompleteDialog extends LitElement {
|
||||
</ha-button>
|
||||
<ha-button
|
||||
@click=${this._complete}
|
||||
.disabled=${this._loading}
|
||||
.disabled=${this._loading || this._missingRequired.length > 0}
|
||||
title=${this._missingRequired.length
|
||||
? this._missingRequired.map((f) => t("err_required", L).replace("{field}", t(REQUIRED_COMPLETION_LABELS[f] ?? f, L))).join(" · ")
|
||||
: ""}
|
||||
>
|
||||
${this._loading ? t("completing", L) : t("complete", L)}
|
||||
</ha-button>
|
||||
@@ -316,6 +371,11 @@ export class MaintenanceCompleteDialog extends LitElement {
|
||||
}
|
||||
|
||||
static styles = [nativeFieldStyles, css`
|
||||
.req-mark {
|
||||
color: var(--error-color, #f44336);
|
||||
margin-left: 2px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.dialog-title {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
@@ -348,6 +408,9 @@ export class MaintenanceCompleteDialog extends LitElement {
|
||||
font-size: 13px; cursor: pointer;
|
||||
}
|
||||
.used-part-check input { cursor: pointer; }
|
||||
/* #111: whose stock this row draws on. Muted but never omitted — an
|
||||
unlabelled foreign pool is indistinguishable from an own part. */
|
||||
.used-part-owner { color: var(--secondary-text-color); }
|
||||
.used-part-qty {
|
||||
width: 76px; padding: 4px 6px; border-radius: 4px; font: inherit; font-size: 13px;
|
||||
border: 1px solid var(--divider-color);
|
||||
|
||||
@@ -155,9 +155,15 @@ export class MaintenanceQrDialog extends LitElement {
|
||||
const completeLabel = escapeHtml(t("qr_action_complete", L));
|
||||
|
||||
w.document.write(`<!DOCTYPE html><html><head><meta charset="utf-8">
|
||||
<meta name="color-scheme" content="light">
|
||||
<title>${safeTitle}</title>
|
||||
<style>
|
||||
body{font-family:sans-serif;text-align:center;padding:20px}
|
||||
/* Printable sheet — must not inherit the phone's dark theme. The QR images
|
||||
carry their own white quiet zone and stay scannable either way, but the
|
||||
labels below are explicit dark greys and would vanish on a WebView's dark
|
||||
canvas. Same reasoning as helpers/report.ts. */
|
||||
:root{color-scheme:light}
|
||||
body{font-family:sans-serif;text-align:center;padding:20px;background:#fff;color:#1a1a1a}
|
||||
h2{margin:0 0 4px}
|
||||
.sub{color:#666;font-size:14px;margin-bottom:16px}
|
||||
.qr-row{display:flex;justify-content:center;gap:24px;margin:12px 0}
|
||||
|
||||
@@ -9,6 +9,15 @@ import { UserService } from "../user-service";
|
||||
import { OBJECT_COLUMNS, sanitizeColumns } from "../helpers/object-columns";
|
||||
import { downloadTextFile } from "../helpers/download";
|
||||
|
||||
/** One household member and the notify services they actually resolve to.
|
||||
* Empty `services` means no Companion device is linked, so their reminders
|
||||
* fall back to the household notification service. */
|
||||
interface PersonNotifyTarget {
|
||||
user_id: string;
|
||||
name: string;
|
||||
services: string[];
|
||||
}
|
||||
|
||||
/* Settings response shape from WS maintenance_supporter/settings */
|
||||
interface SettingsResponse {
|
||||
features: AdvancedFeatures;
|
||||
@@ -27,6 +36,8 @@ interface SettingsResponse {
|
||||
notify_targets?: string[];
|
||||
panel_enabled: boolean;
|
||||
panel_title: string;
|
||||
/** Opt-in copy of the shipped Assist sentences into the config dir. */
|
||||
install_assist_sentences?: boolean;
|
||||
};
|
||||
notifications: {
|
||||
due_soon_enabled: boolean;
|
||||
@@ -108,6 +119,8 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
@state() private _includeHistory = true;
|
||||
@state() private _toast = "";
|
||||
@state() private _testingNotification = false;
|
||||
@state() private _personTargets: PersonNotifyTarget[] = [];
|
||||
@state() private _testingUser = "";
|
||||
@state() private _users: HAUser[] = [];
|
||||
@state() private _savedViews: Array<{ id: string; name: string }> = [];
|
||||
|
||||
@@ -169,6 +182,23 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
} catch {
|
||||
this._users = [];
|
||||
}
|
||||
this._loadNotifyTargets();
|
||||
}
|
||||
|
||||
/** Which notify services each household member resolves to.
|
||||
*
|
||||
* Resolved by the backend through the same helper the reminder path uses,
|
||||
* so the list shown here is the list that will actually be used.
|
||||
*/
|
||||
private async _loadNotifyTargets(): Promise<void> {
|
||||
try {
|
||||
const res = await this.hass.connection.sendMessagePromise<{ targets: PersonNotifyTarget[] }>({
|
||||
type: "maintenance_supporter/notify/user_targets",
|
||||
});
|
||||
this._personTargets = res.targets || [];
|
||||
} catch {
|
||||
this._personTargets = [];
|
||||
}
|
||||
}
|
||||
|
||||
private async _loadSettings(): Promise<void> {
|
||||
@@ -220,11 +250,13 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _sendTestNotification = async (): Promise<void> => {
|
||||
this._testingNotification = true;
|
||||
private _sendTestNotification = async (userId?: string): Promise<void> => {
|
||||
if (userId) this._testingUser = userId;
|
||||
else this._testingNotification = true;
|
||||
try {
|
||||
const res = await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/global/test_notification",
|
||||
...(userId ? { user_id: userId } : {}),
|
||||
}) as { success: boolean; message?: string };
|
||||
const msg = res.message
|
||||
|| (res.success ? t("test_notification_success", this._lang) : t("test_notification_failed", this._lang));
|
||||
@@ -232,7 +264,8 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
} catch {
|
||||
this._showToast(t("test_notification_failed", this._lang));
|
||||
} finally {
|
||||
this._testingNotification = false;
|
||||
if (userId) this._testingUser = "";
|
||||
else this._testingNotification = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -570,6 +603,12 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
@change=${(e: Event) => this._updateSetting("panel_title", (e.target as HTMLInputElement).value.trim())} />
|
||||
</label>
|
||||
` : ""}
|
||||
<label class="setting-row">
|
||||
<span class="setting-label">${t("settings_install_assist_sentences", L)}</span>
|
||||
<input type="checkbox" .checked=${g.install_assist_sentences ?? false}
|
||||
@change=${(e: Event) => this._updateSetting("install_assist_sentences", (e.target as HTMLInputElement).checked)} />
|
||||
</label>
|
||||
<div class="setting-hint">${t("settings_install_assist_sentences_hint", L)}</div>
|
||||
<label class="setting-row">
|
||||
<span class="setting-label">${t("settings_notifications", L)}</span>
|
||||
<input type="checkbox" .checked=${g.notifications_enabled}
|
||||
@@ -588,10 +627,28 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
<span class="setting-label">${t("test_notification", L)}</span>
|
||||
<button class="ha-button secondary"
|
||||
?disabled=${!g.notify_service || this._testingNotification}
|
||||
@click=${this._sendTestNotification}>
|
||||
@click=${() => this._sendTestNotification()}>
|
||||
${this._testingNotification ? t("testing", L) : t("send_test", L)}
|
||||
</button>
|
||||
</div>
|
||||
${this._personTargets.length ? html`
|
||||
<div class="notify-per-person">
|
||||
<span class="setting-label">${t("notify_per_person", L)}</span>
|
||||
${this._personTargets.map((target) => html`
|
||||
<div class="notify-person-row">
|
||||
<span class="notify-person-name">${target.name}</span>
|
||||
<span class="notify-person-target ${target.services.length ? "" : "muted"}">
|
||||
${target.services.length ? target.services.join(", ") : t("notify_no_own_device", L)}
|
||||
</span>
|
||||
<button class="ha-button secondary"
|
||||
?disabled=${!target.services.length || this._testingUser === target.user_id}
|
||||
@click=${() => this._sendTestNotification(target.user_id)}>
|
||||
${this._testingUser === target.user_id ? t("testing", L) : t("send_test", L)}
|
||||
</button>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
` : nothing}
|
||||
` : nothing}
|
||||
</div>
|
||||
`;
|
||||
@@ -1606,6 +1663,31 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
cursor: pointer;
|
||||
gap: 12px;
|
||||
}
|
||||
.notify-per-person {
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--divider-color, #e0e0e0);
|
||||
}
|
||||
.notify-person-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 6px 0 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.notify-person-name {
|
||||
font-weight: 500;
|
||||
min-width: 120px;
|
||||
}
|
||||
.notify-person-target {
|
||||
flex: 1;
|
||||
min-width: 160px;
|
||||
font-size: 0.9em;
|
||||
word-break: break-word;
|
||||
color: var(--secondary-text-color, #727272);
|
||||
}
|
||||
.notify-person-target.muted {
|
||||
font-style: italic;
|
||||
}
|
||||
/* v2.27: template gallery clustered by category */
|
||||
.tpl-group { margin-top: 14px; }
|
||||
.tpl-group-head {
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
import { LitElement, html, css, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import type { AdaptiveConfig, HomeAssistant, MaintenanceTask, TriggerConfig, HAUser } from "../types";
|
||||
import type { AdaptiveConfig, HomeAssistant, MaintenanceTask, TaskPartLink, TriggerConfig, HAUser } from "../types";
|
||||
import { formatDate, t, weekdayName } from "../styles";
|
||||
import { UserService } from "../user-service";
|
||||
import { partLinkKey } from "../helpers/shared-parts";
|
||||
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { REQUIRED_COMPLETION_KEYS, REQUIRED_COMPLETION_LABELS } from "./required-completion-labels";
|
||||
import "./ms-textfield";
|
||||
|
||||
const MAINTENANCE_TYPE_KEYS = ["cleaning", "inspection", "replacement", "calibration", "service", "reading", "custom"];
|
||||
@@ -122,6 +124,14 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
@property({ type: Number, attribute: "default-warning-days" }) public defaultWarningDays = 7;
|
||||
/** The object's spare parts — offered as "consumes parts" checkboxes. */
|
||||
@state() private parts: Array<{ id: string; name: string; unit?: string }> = [];
|
||||
/** #111: OTHER objects that own spare parts, so a task can draw on a shared
|
||||
* pool (three vacuums, one box of dust bags). Grouped by owner in the UI —
|
||||
* which pool a link means must never be a guess. */
|
||||
@state() private _foreignOwners: Array<{
|
||||
entry_id: string;
|
||||
name: string;
|
||||
parts: Array<{ id: string; name: string; unit?: string }>;
|
||||
}> = [];
|
||||
@state() private _open = false;
|
||||
@state() private _loading = false;
|
||||
@state() private _error = "";
|
||||
@@ -210,7 +220,9 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
@state() private _nfcTagId = "";
|
||||
// v2.20 (#83): unit for `reading`-type tasks ("kWh", "m³", ...)
|
||||
@state() private _readingUnit = "";
|
||||
@state() private _consumesParts: Record<string, number> = {};
|
||||
/** 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> = {};
|
||||
@state() private _partsLoadFailed = false;
|
||||
@state() private _availableTags: Array<{id: string; name: string}> = [];
|
||||
|
||||
@@ -222,6 +234,9 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
|
||||
// Checklist (newline-separated steps, one per line)
|
||||
@state() private _checklistText = "";
|
||||
/** Details this task demands on completion — enforced by the backend on
|
||||
* every surface, so a button press or voice command cannot bypass it. */
|
||||
@state() private _requiredCompletion: string[] = [];
|
||||
|
||||
// Schedule time (HH:MM, advanced feature)
|
||||
@state() private _scheduleTime = "";
|
||||
@@ -271,7 +286,7 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
this._objectChoices = [];
|
||||
}
|
||||
this._resetFields();
|
||||
await Promise.all([this._loadUsers(), this._loadTags(), this._loadParts()]);
|
||||
await Promise.all([this._loadUsers(), this._loadTags(), this._loadParts(), this._loadForeignPools()]);
|
||||
this._open = true;
|
||||
}
|
||||
|
||||
@@ -321,12 +336,17 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
this._lastPerformed = task.last_performed || "";
|
||||
this._nfcTagId = task.nfc_tag_id || "";
|
||||
this._readingUnit = task.reading_unit || "";
|
||||
this._consumesParts = Object.fromEntries((task.consumes_parts || []).map((l) => [l.part_id, l.quantity]));
|
||||
// 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(
|
||||
(task.consumes_parts || []).map((l) => [partLinkKey(l), { ...l }]),
|
||||
);
|
||||
this._responsibleUserId = task.responsible_user_id || null;
|
||||
this._assigneePool = [...(task.assignee_pool || [])];
|
||||
this._rotationStrategy = task.rotation_strategy || "";
|
||||
|
||||
this._checklistText = (task.checklist || []).join("\n");
|
||||
this._requiredCompletion = [...(task.required_completion_fields || [])];
|
||||
this._scheduleTime = task.schedule_time || "";
|
||||
|
||||
// v1.3.0: hydrate on_complete_action + quick_complete_defaults
|
||||
@@ -395,7 +415,7 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
this._fetchEntityAttributes(this._triggerEntityId);
|
||||
}
|
||||
|
||||
await Promise.all([this._loadUsers(), this._loadTags(), this._loadParts()]);
|
||||
await Promise.all([this._loadUsers(), this._loadTags(), this._loadParts(), this._loadForeignPools()]);
|
||||
this._open = true;
|
||||
}
|
||||
|
||||
@@ -434,6 +454,7 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
this._assigneePool = [];
|
||||
this._rotationStrategy = "";
|
||||
this._checklistText = "";
|
||||
this._requiredCompletion = [];
|
||||
this._scheduleTime = "";
|
||||
this._environmentalEntity = "";
|
||||
this._environmentalAttribute = "";
|
||||
@@ -736,6 +757,40 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
/** #111: the other objects' spare-part pools this task could draw on.
|
||||
*
|
||||
* A sibling of `_loadParts`, run in the SAME Promise.all rather than nested
|
||||
* inside it: chaining it after the own-parts fetch delays the dialog opening
|
||||
* by a further round trip for a list that is secondary to it.
|
||||
*
|
||||
* Failure is soft on purpose — the own-parts picker is the primary path and
|
||||
* must not disappear because this second call did not come back. */
|
||||
private async _loadForeignPools(): Promise<void> {
|
||||
this._foreignOwners = [];
|
||||
if (!this._entryId) return;
|
||||
try {
|
||||
const result = (await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/objects",
|
||||
})) as {
|
||||
objects?: Array<{
|
||||
entry_id: string;
|
||||
object?: { name?: string };
|
||||
parts?: Array<{ id: string; name: string; unit?: string }>;
|
||||
}>;
|
||||
};
|
||||
this._foreignOwners = (result.objects || [])
|
||||
.filter((o) => o.entry_id !== this._entryId && (o.parts || []).length > 0)
|
||||
.map((o) => ({
|
||||
entry_id: o.entry_id,
|
||||
name: o.object?.name || o.entry_id,
|
||||
parts: o.parts || [],
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
} catch {
|
||||
this._foreignOwners = [];
|
||||
}
|
||||
}
|
||||
|
||||
private async _loadTags(): Promise<void> {
|
||||
try {
|
||||
const result = await this.hass.connection.sendMessagePromise({
|
||||
@@ -773,6 +828,69 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
/** A task already drawing on a shared pool opens that section expanded — a
|
||||
* collapsed disclosure would hide a link that is very much active. */
|
||||
private get _hasForeignPick(): boolean {
|
||||
return Object.values(this._consumesParts).some((l) => !!l.entry_id);
|
||||
}
|
||||
|
||||
/** One "consumes parts" checkbox + quantity.
|
||||
*
|
||||
* `ownerEntryId` is undefined for the object's own parts and set for a pool
|
||||
* owned by another object (#111) — that argument is the ONLY difference
|
||||
* between the two lists, which is why they share this renderer. */
|
||||
private _renderConsumesRow(
|
||||
part: { id: string; name: string; unit?: string },
|
||||
ownerEntryId?: string,
|
||||
) {
|
||||
const key = partLinkKey({ part_id: part.id, entry_id: ownerEntryId });
|
||||
const link = this._consumesParts[key];
|
||||
const base: TaskPartLink = ownerEntryId
|
||||
? { part_id: part.id, quantity: 1, entry_id: ownerEntryId }
|
||||
: { part_id: part.id, quantity: 1 };
|
||||
return html`
|
||||
<div class="consumes-row">
|
||||
<label class="consumes-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
.checked=${link !== undefined}
|
||||
@change=${(e: Event) => {
|
||||
const next = { ...this._consumesParts };
|
||||
if ((e.target as HTMLInputElement).checked) next[key] = next[key] || base;
|
||||
else delete next[key];
|
||||
this._consumesParts = next;
|
||||
}}
|
||||
/>
|
||||
<span>${part.name}${part.unit ? ` (${part.unit})` : ""}</span>
|
||||
</label>
|
||||
${link !== undefined
|
||||
? html`<input
|
||||
class="consumes-qty"
|
||||
type="number"
|
||||
min="0.01"
|
||||
max="999"
|
||||
step="0.01"
|
||||
.value=${String(link.quantity)}
|
||||
@input=${(e: Event) => {
|
||||
const v = parseFloat((e.target as HTMLInputElement).value);
|
||||
this._consumesParts = {
|
||||
...this._consumesParts,
|
||||
[key]: { ...base, quantity: Number.isFinite(v) && v >= 0.01 ? v : 1 },
|
||||
};
|
||||
}}
|
||||
/>`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _toggleRequired(field: string, on: boolean): void {
|
||||
const next = new Set(this._requiredCompletion);
|
||||
if (on) next.add(field);
|
||||
else next.delete(field);
|
||||
this._requiredCompletion = [...next];
|
||||
}
|
||||
|
||||
private async _save(): Promise<void> {
|
||||
if (this._loading) return; // synchronous re-entry guard (double-click)
|
||||
if (!this._name.trim()) return;
|
||||
@@ -787,7 +905,16 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
name: this._name,
|
||||
task_type: this._type,
|
||||
schedule_type: this._scheduleType,
|
||||
warning_days: parseInt(this._warningDays, 10) || 7,
|
||||
// `0` is a legal, meaningful value — "no due-soon window, go straight
|
||||
// from ok to overdue" (backend range is 0–365). The old
|
||||
// `parseInt(...) || 7` treated it as falsy and silently rewrote a
|
||||
// stored 0 to 7 on EVERY save, even when the user never touched the
|
||||
// field. Same class as bug #42, but worse: it needed no user action.
|
||||
// Only a genuinely unparseable field falls back, and to the
|
||||
// configured default rather than a hardcoded 7.
|
||||
warning_days: Number.isNaN(parseInt(this._warningDays, 10))
|
||||
? this.defaultWarningDays
|
||||
: Math.max(0, parseInt(this._warningDays, 10)),
|
||||
};
|
||||
const ecd = this._earliestCompletionDays.trim();
|
||||
data.earliest_completion_days = ecd === "" ? null : Math.max(0, parseInt(ecd, 10) || 0);
|
||||
@@ -836,14 +963,20 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
data.last_performed = this._lastPerformed || null;
|
||||
data.nfc_tag_id = this._nfcTagId || null;
|
||||
data.reading_unit = this._readingUnit.trim() || null;
|
||||
if (this.parts.length) {
|
||||
data.consumes_parts = Object.entries(this._consumesParts).map(([part_id, quantity]) => ({
|
||||
part_id,
|
||||
quantity,
|
||||
}));
|
||||
// Only send when a picker was actually rendered. A failed parts load
|
||||
// leaves both lists empty, and sending [] then would silently wipe links
|
||||
// the user never saw. entry_id is written ONLY for a foreign pick, so an
|
||||
// own-parts task saves byte-identically to before (#111).
|
||||
if (this.parts.length || this._foreignOwners.length) {
|
||||
data.consumes_parts = Object.values(this._consumesParts).map((l) =>
|
||||
l.entry_id
|
||||
? { part_id: l.part_id, quantity: l.quantity, entry_id: l.entry_id }
|
||||
: { part_id: l.part_id, quantity: l.quantity },
|
||||
);
|
||||
}
|
||||
data.responsible_user_id = this._responsibleUserId;
|
||||
data.assignee_pool = this._assigneePool;
|
||||
data.required_completion_fields = this._requiredCompletion;
|
||||
data.rotation_strategy =
|
||||
this._assigneePool.length >= 2 && this._rotationStrategy
|
||||
? this._rotationStrategy
|
||||
@@ -1700,6 +1833,9 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
this._entryId = (e.target as HTMLSelectElement).value;
|
||||
this._consumesParts = {};
|
||||
this._loadParts();
|
||||
// The new owner drops out of the shared-pool list and the old
|
||||
// one joins it, so this has to be recomputed too (#111).
|
||||
this._loadForeignPools();
|
||||
}}
|
||||
>
|
||||
${this._objectChoices.map(
|
||||
@@ -1738,44 +1874,27 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
${this._partsLoadFailed
|
||||
? html`<div class="field-help parts-load-failed">${t("parts_load_failed", L)}</div>`
|
||||
: nothing}
|
||||
${this.parts.length
|
||||
${this.parts.length || this._foreignOwners.length
|
||||
? html`
|
||||
<div class="field">
|
||||
<label>${t("consumes_parts_label", L)}</label>
|
||||
${this.parts.map((part) => {
|
||||
const qty = this._consumesParts[part.id];
|
||||
return html`
|
||||
<div class="consumes-row">
|
||||
<label class="consumes-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
.checked=${qty !== undefined}
|
||||
@change=${(e: Event) => {
|
||||
const next = { ...this._consumesParts };
|
||||
if ((e.target as HTMLInputElement).checked) next[part.id] = next[part.id] || 1;
|
||||
else delete next[part.id];
|
||||
this._consumesParts = next;
|
||||
}}
|
||||
/>
|
||||
<span>${part.name}${part.unit ? ` (${part.unit})` : ""}</span>
|
||||
</label>
|
||||
${qty !== undefined
|
||||
? html`<input
|
||||
class="consumes-qty"
|
||||
type="number"
|
||||
min="0.01"
|
||||
max="999"
|
||||
step="0.01"
|
||||
.value=${String(qty)}
|
||||
@input=${(e: Event) => {
|
||||
const v = parseFloat((e.target as HTMLInputElement).value);
|
||||
this._consumesParts = { ...this._consumesParts, [part.id]: Number.isFinite(v) && v >= 0.01 ? v : 1 };
|
||||
}}
|
||||
/>`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
${this.parts.map((part) => this._renderConsumesRow(part))}
|
||||
${this._foreignOwners.length
|
||||
? html`
|
||||
<details class="shared-pools" ?open=${this._hasForeignPick}>
|
||||
<summary>${t("shared_parts_other_objects", L)}</summary>
|
||||
<div class="field-help">${t("shared_parts_help", L)}</div>
|
||||
${this._foreignOwners.map(
|
||||
(owner) => html`
|
||||
<div class="shared-pool-owner">${owner.name}</div>
|
||||
${owner.parts.map((part) =>
|
||||
this._renderConsumesRow(part, owner.entry_id),
|
||||
)}
|
||||
`,
|
||||
)}
|
||||
</details>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
@@ -1857,6 +1976,8 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
<ms-textfield
|
||||
label="${t("warning_days", L)}"
|
||||
type="number"
|
||||
min="0"
|
||||
max="365"
|
||||
.value=${this._warningDays}
|
||||
@input=${(e: Event) => (this._warningDays = (e.target as HTMLInputElement).value)}
|
||||
></ms-textfield>
|
||||
@@ -1879,6 +2000,19 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
></textarea>
|
||||
<div class="field-help">${t("checklist_help", L)}</div>
|
||||
` : nothing}
|
||||
<h3>${t("require_on_completion", L)}</h3>
|
||||
<div class="required-completion">
|
||||
${REQUIRED_COMPLETION_KEYS.map((field) => html`
|
||||
<label class="req-option">
|
||||
<input
|
||||
type="checkbox"
|
||||
.checked=${this._requiredCompletion.includes(field)}
|
||||
@change=${(e: Event) => this._toggleRequired(field, (e.target as HTMLInputElement).checked)}
|
||||
/>
|
||||
<span>${t(REQUIRED_COMPLETION_LABELS[field], L)}</span>
|
||||
</label>
|
||||
`)}
|
||||
</div>
|
||||
<ms-textfield
|
||||
label="${t("last_performed_optional", L)}"
|
||||
type="date"
|
||||
@@ -2127,6 +2261,24 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
background: var(--card-background-color);
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
/* #111: other objects' pools sit behind a disclosure so the object's OWN
|
||||
parts stay the primary list; each group is headed by the owning object's
|
||||
name, so which pool a checkbox means is never a guess. */
|
||||
.shared-pools {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.shared-pools > summary {
|
||||
cursor: pointer;
|
||||
padding: 2px 0;
|
||||
font-size: 13px;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.shared-pool-owner {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.field-help {
|
||||
font-size: 12px;
|
||||
color: var(--secondary-text-color);
|
||||
|
||||
+4
-2
@@ -14,7 +14,7 @@
|
||||
|
||||
import { LitElement, html, css, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { sharedStyles, t, STATUS_COLORS, formatDate, formatDateTime, formatRecurrence } from "../styles";
|
||||
import { sharedStyles, t, STATUS_COLORS, formatDate, formatDateTime, formatInterval, formatRecurrence } from "../styles";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { renderWeibullSection } from "../renderers/weibull";
|
||||
import { renderPredictionSection } from "../renderers/prediction";
|
||||
@@ -294,7 +294,9 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
|
||||
task_id: this._taskId,
|
||||
});
|
||||
this._toast = r.recommended_interval
|
||||
? `${t("reanalyze_result", this._lang) || "Recomputed"}: ${r.recommended_interval}d (${r.data_points} pts)`
|
||||
// The analyzer always works in DAYS (helpers/interval_analyzer.py), so
|
||||
// the unit is pinned here rather than taken from the task's own unit.
|
||||
? `${t("reanalyze_result", this._lang) || "Recomputed"}: ${formatInterval(r.recommended_interval, "days", this._lang)} (${r.data_points} pts)`
|
||||
: (t("reanalyze_insufficient_data", this._lang) || "Not enough data");
|
||||
await this._loadTask();
|
||||
setTimeout(() => { this._toast = ""; }, 3500);
|
||||
|
||||
Reference in New Issue
Block a user