This commit is contained in:
Home Assistant Version Control
2026-08-06 15:24:19 +00:00
parent 6aaf37b9bc
commit d5da256341
93 changed files with 5199 additions and 8790 deletions
@@ -29,6 +29,10 @@ interface BatteryRow {
* the fleet floor, whichever is higher) — the level bar colors against
* it, not against a fixed 20 %. */
low_threshold?: number;
/** B1: the predicted date has passed while the battery still reports
* healthy. Never escalates to low/task — the row just shows the
* discrepancy (usual cause: an unrecorded swap). */
forecast_overdue?: boolean;
}
interface RosterRow extends BatteryRow {
status: "low" | "soon" | "ok";
@@ -466,13 +470,15 @@ export class MaintenanceBatteryFleetSection extends LitElement {
})()}
${b.days_until != null
? html`<span
class="bf-predicted ${b.predicted_source === "trend" ? "bf-trend" : ""}"
title=${b.predicted_source === "trend"
? t("battery_fleet_predicted_trend", L)
.replace("{date}", this._predictedDate(b.days_until))
.replace("{confidence}", t("cal_confidence_" + (b.prediction_confidence || "medium"), L))
: t("battery_fleet_predicted_on", L).replace("{date}", this._predictedDate(b.days_until))}
>~${this._predictedDate(b.days_until)}</span
class="bf-predicted ${b.predicted_source === "trend" ? "bf-trend" : ""} ${b.forecast_overdue ? "bf-overdue" : ""}"
title=${b.forecast_overdue
? t("battery_fleet_forecast_overdue", L)
: b.predicted_source === "trend"
? t("battery_fleet_predicted_trend", L)
.replace("{date}", this._predictedDate(b.days_until))
.replace("{confidence}", t("cal_confidence_" + (b.prediction_confidence || "medium"), L))
: t("battery_fleet_predicted_on", L).replace("{date}", this._predictedDate(b.days_until))}
>${b.forecast_overdue ? html`<ha-icon icon="mdi:calendar-alert"></ha-icon>` : nothing}~${this._predictedDate(b.days_until)}</span
>`
: nothing}
<button
@@ -770,6 +776,16 @@ export class MaintenanceBatteryFleetSection extends LitElement {
text-decoration: underline dotted;
text-underline-offset: 2px;
}
/* B1: passed prediction on a still-healthy battery — warn-tinted with a
calendar-alert icon; the tooltip explains (record the swap / forecast
was off). Deliberately NOT red: this is a discrepancy, not an alarm. */
.bf-predicted.bf-overdue {
color: var(--warning-color, #ff9800);
}
.bf-predicted.bf-overdue ha-icon {
--mdc-icon-size: 14px;
margin-right: 2px;
}
.bf-total {
font-size: 12px;
color: var(--secondary-text-color);
@@ -21,6 +21,11 @@ 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;
/** #104 follow-up: the buy task's part unit cost — powers the cost
* suggestion (restock qty × unit cost). */
@property({ attribute: false }) public restockUnitCost: number | null = null;
/** Currency symbol for the cost suggestion ("" = plain number). */
@property() public currencySymbol = "";
/** #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. */
@@ -215,6 +220,47 @@ export class MaintenanceCompleteDialog extends LitElement {
return this.requiredFields.includes(field) ? html`<span class="req-mark" aria-hidden="true">*</span>` : nothing;
}
/** #104 follow-up: suggested cost derived from the parts this completion
* touches — the SELECTED "parts used" (qty × each part's unit cost) on a
* consuming task, or restock qty × unit cost on a buy task. Null when no
* involved part carries a price. Follows the live selection, so ticking
* a part off updates the suggestion. */
private _partsCostSuggestion(): number | null {
if (this.restockDefault !== null) {
const qty = parseFloat(this._restockQty);
if (this.restockUnitCost == null || !Number.isFinite(qty) || qty <= 0) return null;
return Math.round(this.restockUnitCost * qty * 100) / 100;
}
if (!this.parts.length) return null;
let sum = 0;
let priced = false;
for (const link of Object.values(this._usedParts)) {
const def = this.parts.find(
(pt) => partLinkKey({ part_id: pt.id, entry_id: pt.entry_id }) === partLinkKey(link),
);
if (def?.cost != null) {
sum += def.cost * (link.quantity || 1);
priced = true;
}
}
return priced ? Math.round(sum * 100) / 100 : null;
}
/** The one-click "use ≈ X from parts" chip under the cost field. Hidden
* once the user typed a cost themselves — a suggestion, never an
* overwrite. */
private _renderCostSuggestion(L: string) {
if (this._cost.trim() !== "") return nothing;
const suggestion = this._partsCostSuggestion();
if (suggestion == null || suggestion <= 0) return nothing;
const amount = `${suggestion.toFixed(2)}${this.currencySymbol ? ` ${this.currencySymbol}` : ""}`;
return html`<button
type="button"
class="cost-suggestion"
@click=${() => (this._cost = suggestion.toFixed(2))}
>${t("cost_from_parts", L).replace("{amount}", amount)}</button>`;
}
private _close(): void {
this._open = false;
}
@@ -317,6 +363,7 @@ export class MaintenanceCompleteDialog extends LitElement {
<input type="number" step="0.01" min="0" class="field-input"
.value=${this._cost}
@input=${(e: Event) => (this._cost = (e.target as HTMLInputElement).value)} />
${this._renderCostSuggestion(L)}
</label>
<label class="field">
<span class="field-label">${t("duration_minutes", L)}${this._req("duration")}</span>
@@ -386,6 +433,19 @@ export class MaintenanceCompleteDialog extends LitElement {
margin-left: 2px;
font-weight: 600;
}
/* #104: one-click cost suggestion from parts — quiet link-style chip. */
.cost-suggestion {
align-self: flex-start;
margin-top: 4px;
padding: 0;
border: none;
background: none;
color: var(--primary-color);
font-size: 12.5px;
cursor: pointer;
text-decoration: underline dotted;
text-underline-offset: 2px;
}
.dialog-title {
font-size: 18px;
font-weight: 500;