198 files
This commit is contained in:
+73
@@ -75,6 +75,8 @@ interface Overview {
|
||||
available: boolean;
|
||||
configured: boolean;
|
||||
task_ok?: boolean;
|
||||
/** The fleet object's entry (its parts are the battery types). */
|
||||
entry_id?: string | null;
|
||||
total: number;
|
||||
low: BatteryRow[];
|
||||
soon: BatteryRow[];
|
||||
@@ -84,6 +86,9 @@ interface Overview {
|
||||
needs_now: Record<string, number>;
|
||||
needs_soon: Record<string, number>;
|
||||
types: string[];
|
||||
// #208: the counted rechargeable spares — charged (stock) and waiting to
|
||||
// be charged (restoring). An older backend omits it.
|
||||
charging?: { part_id: string; name: string; reuse: string; stock: number; restoring: number }[];
|
||||
excluded?: { entity_id: string; device_name: string }[];
|
||||
// #135 follow-up: fleet-wide "track self-charging devices" opt-in.
|
||||
track_self_charging?: boolean;
|
||||
@@ -550,6 +555,34 @@ export class MaintenanceBatteryFleetSection extends LitElement {
|
||||
: nothing}
|
||||
`}
|
||||
|
||||
${ov.charging?.length
|
||||
? html`
|
||||
<div class="bf-charging">
|
||||
<span class="bf-label">${t("battery_fleet_charging", L)}</span>
|
||||
${ov.charging.map(
|
||||
(c) => html`<span class="bf-charge-item">
|
||||
<span class="bf-charge-name">${c.name}</span>
|
||||
<span>${t("battery_fleet_ready", L).replace("{count}", String(c.stock))}</span>
|
||||
${c.restoring > 0
|
||||
? html`<span class="bf-charge-waiting">${t(`part_waiting_${c.reuse}`, L).replace("{count}", String(c.restoring))}</span>
|
||||
${this._canWrite
|
||||
? html`<button
|
||||
class="bf-charged"
|
||||
title=${t("part_ready_again_hint", L)}
|
||||
.disabled=${this._marking}
|
||||
@click=${() => this._act({
|
||||
type: "maintenance_supporter/part/ready_again",
|
||||
entry_id: ov.entry_id,
|
||||
part_id: c.part_id,
|
||||
})}
|
||||
><ha-icon icon="mdi:battery-charging-100"></ha-icon>${t(`part_reuse_${c.reuse}`, L)}</button>`
|
||||
: nothing}`
|
||||
: nothing}
|
||||
</span>`,
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
${ov.soon.length
|
||||
? html`
|
||||
<div class="bf-soon">
|
||||
@@ -730,6 +763,46 @@ export class MaintenanceBatteryFleetSection extends LitElement {
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
.bf-charging {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.bf-charge-item {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
background: var(--secondary-background-color, #eee);
|
||||
font-size: 13px;
|
||||
}
|
||||
.bf-charge-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
.bf-charge-waiting {
|
||||
color: var(--warning-color, #f57c00);
|
||||
}
|
||||
.bf-charged {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
min-height: 28px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--primary-color);
|
||||
background: transparent;
|
||||
color: var(--primary-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
.bf-charged ha-icon {
|
||||
--mdc-icon-size: 16px;
|
||||
}
|
||||
.bf-label {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
|
||||
+13
-11
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import { LitElement, html, css, nothing } from "lit";
|
||||
import { isSafeHttpUrl } from "../helpers/url";
|
||||
import { isSafeLink } from "../helpers/url";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { t, ensureLocale, langOf } from "../styles";
|
||||
import { runWs } from "../helpers/ws-run";
|
||||
@@ -230,17 +230,18 @@ export class MaintenanceDocumentsSection extends LitElement {
|
||||
this._hint = "";
|
||||
let deduped = 0;
|
||||
let dupInObject = 0;
|
||||
const refused: string[] = [];
|
||||
try {
|
||||
// Bulk upload: one refused file (too large, rejected) is noted and
|
||||
// the rest still go up — unlike the photo dialogs, which stop.
|
||||
// Bulk upload: one refused file (too large, rejected, the connection
|
||||
// dropped) is noted by name and the rest still go up — unlike the
|
||||
// photo dialogs, which stop.
|
||||
for (const file of files) {
|
||||
let doc;
|
||||
try {
|
||||
doc = await uploadDocument(this.hass, entryId, file, [cat]);
|
||||
} catch (e) {
|
||||
const key = e instanceof Error ? e.message : "";
|
||||
if (key !== "doc_too_large" && key !== "doc_upload_failed") throw e;
|
||||
this._error = t(key, this._lang);
|
||||
const key = e instanceof Error && e.message === "doc_too_large" ? "doc_too_large" : "doc_upload_failed";
|
||||
refused.push(`${file.name}: ${t(key, this._lang)}`);
|
||||
continue;
|
||||
}
|
||||
if (doc.duplicate_in_object) dupInObject++;
|
||||
@@ -250,9 +251,10 @@ export class MaintenanceDocumentsSection extends LitElement {
|
||||
if (dupInObject) this._hint = t("doc_dup_in_object", this._lang);
|
||||
else if (deduped) this._hint = t("doc_deduped", this._lang);
|
||||
await this._load();
|
||||
} catch {
|
||||
this._error = t("doc_upload_failed", this._lang);
|
||||
} finally {
|
||||
// Said AFTER the reload: _load() clears the error line, so a refusal
|
||||
// set before it flashed for a moment and was gone (#209).
|
||||
if (refused.length && entryId === this.entryId) this._error = refused.join(" · ");
|
||||
this._busy = false;
|
||||
}
|
||||
}
|
||||
@@ -277,7 +279,7 @@ export class MaintenanceDocumentsSection extends LitElement {
|
||||
if (doc.kind === "file") void this._preview(doc);
|
||||
// Only open http(s) links — never a javascript:/data: URL (the same scheme
|
||||
// guard the rest of the panel applies before opening user-supplied URLs).
|
||||
else if (isSafeHttpUrl(doc.url)) window.open(doc.url, "_blank", "noopener");
|
||||
else if (isSafeLink(doc.url)) window.open(doc.url, "_blank", "noopener");
|
||||
}
|
||||
|
||||
private _startEdit(doc: MaintenanceDocument): void {
|
||||
@@ -340,7 +342,7 @@ export class MaintenanceDocumentsSection extends LitElement {
|
||||
this._linkDescription = "";
|
||||
this._addingLink = false;
|
||||
},
|
||||
"doc_link_invalid",
|
||||
"doc_link_not_allowed",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -521,7 +523,7 @@ export class MaintenanceDocumentsSection extends LitElement {
|
||||
</button>`
|
||||
: html`<a
|
||||
class="icon-btn"
|
||||
href=${isSafeHttpUrl(doc.url) ? doc.url : "#"}
|
||||
href=${isSafeLink(doc.url) ? doc.url : "#"}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title=${t("doc_open", L)}
|
||||
|
||||
+7
-5
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
|
||||
import { LitElement, html, css, nothing } from "lit";
|
||||
import { isSafeHttpUrl } from "../helpers/url";
|
||||
import { isSafeHttpUrl, isSafeLink } from "../helpers/url";
|
||||
import { renderNotesMarkdown } from "../helpers/notes-markdown";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { t, langOf } from "../styles";
|
||||
@@ -231,26 +231,28 @@ export class MaintenanceObjectQuickActionsDialog extends LitElement {
|
||||
|
||||
private _renderMetaRow(obj: MaintenanceObject) {
|
||||
const L = this._lang;
|
||||
const items: Array<[string, string]> = [];
|
||||
const items: Array<[string, string, boolean?]> = [];
|
||||
if (obj.area_id) items.push([t("area", L), obj.area_id]);
|
||||
if (obj.manufacturer) items.push([t("manufacturer", L), obj.manufacturer]);
|
||||
if (obj.model) items.push([t("model", L), obj.model]);
|
||||
if (obj.serial_number) items.push([t("serial_number_label", L), obj.serial_number]);
|
||||
if (obj.installation_date) items.push([t("installed", L), obj.installation_date]);
|
||||
if (obj.warranty_expiry) items.push([t("warranty", L), obj.warranty_expiry]);
|
||||
if (obj.documentation_url) items.push([t("documentation_url_label", L), obj.documentation_url]);
|
||||
// The documentation link may also open an app (#207).
|
||||
if (obj.documentation_url) items.push([t("documentation_url_label", L), obj.documentation_url, isSafeLink(obj.documentation_url)]);
|
||||
|
||||
if (items.length === 0) return nothing;
|
||||
return html`
|
||||
<div class="meta">
|
||||
${items.map(
|
||||
([label, value]) => html`
|
||||
([label, value, docLink]) => html`
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">${label}</span>
|
||||
<span class="meta-value">${
|
||||
// Only render http(s) values as links (never javascript:/data:);
|
||||
// value-based so it works in every UI language, not just English.
|
||||
isSafeHttpUrl(value)
|
||||
// The documentation link was checked by the app-link rule above.
|
||||
docLink || isSafeHttpUrl(value)
|
||||
? html`<a href="${value}" target="_blank" rel="noopener noreferrer">${value}</a>`
|
||||
: value
|
||||
}</span>
|
||||
|
||||
@@ -22,6 +22,9 @@ import { restockUnits, unitPrice } from "../helpers/parts-cost";
|
||||
// Per-part document links (v2.26) — the task-documents component in part mode.
|
||||
import "./task-documents";
|
||||
|
||||
/** #208: what readies a used part again (helpers/parts.py REUSE_KINDS). */
|
||||
export const REUSE_KINDS = ["charge", "sharpen", "clean", "dry", "refill", "restore"] as const;
|
||||
|
||||
interface PartForm {
|
||||
id?: string;
|
||||
name: string;
|
||||
@@ -33,6 +36,9 @@ interface PartForm {
|
||||
unit: string;
|
||||
cost: string;
|
||||
stock: string;
|
||||
/** #208: "" = used up, else what readies a used one again. */
|
||||
reuse: string;
|
||||
restoring: string;
|
||||
reorder_threshold: string;
|
||||
restock_quantity: string;
|
||||
package_size: string;
|
||||
@@ -50,6 +56,8 @@ const EMPTY_FORM: PartForm = {
|
||||
unit: "",
|
||||
cost: "",
|
||||
stock: "",
|
||||
reuse: "",
|
||||
restoring: "",
|
||||
reorder_threshold: "",
|
||||
restock_quantity: "",
|
||||
package_size: "",
|
||||
@@ -112,6 +120,8 @@ export class MaintenancePartsSection extends LitElement {
|
||||
unit: part.unit || "",
|
||||
cost: part.cost != null ? String(part.cost) : "",
|
||||
stock: part.stock != null ? String(part.stock) : "",
|
||||
reuse: part.reuse || "",
|
||||
restoring: part.restoring ? String(part.restoring) : "",
|
||||
reorder_threshold: part.reorder_threshold != null ? String(part.reorder_threshold) : "",
|
||||
restock_quantity: part.restock_quantity != null ? String(part.restock_quantity) : "",
|
||||
package_size: part.package_size != null ? String(part.package_size) : "",
|
||||
@@ -133,6 +143,8 @@ export class MaintenancePartsSection extends LitElement {
|
||||
unit: f.unit.trim() || null,
|
||||
cost: num(f.cost),
|
||||
stock: num(f.stock),
|
||||
reuse: f.reuse || null,
|
||||
...(f.reuse ? { restoring: num(f.restoring) ?? 0 } : {}),
|
||||
reorder_threshold: num(f.reorder_threshold),
|
||||
restock_quantity: num(f.restock_quantity),
|
||||
package_size: num(f.package_size),
|
||||
@@ -193,6 +205,23 @@ export class MaintenancePartsSection extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
/** #208: the used ones of a reusable part are ready again (charged,
|
||||
* sharpened, ...) — back from the pile to the stock. */
|
||||
private async _readyAgain(part: MaintenancePart): Promise<void> {
|
||||
if (this._busy) return;
|
||||
const result = await this._send<{ stock: number; restoring: number }>({
|
||||
type: "maintenance_supporter/part/ready_again",
|
||||
entry_id: this.entryId,
|
||||
part_id: part.id,
|
||||
});
|
||||
if (result) {
|
||||
part.stock = result.stock;
|
||||
part.restoring = result.restoring;
|
||||
this.requestUpdate();
|
||||
this._notifyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private _identLine(part: MaintenancePart): string {
|
||||
return [part.vendor, part.mpn ? `MPN: ${part.mpn}` : "", part.gtin ? `GTIN: ${part.gtin}` : ""]
|
||||
.filter(Boolean)
|
||||
@@ -220,6 +249,17 @@ export class MaintenancePartsSection extends LitElement {
|
||||
>`
|
||||
: nothing}
|
||||
</div>
|
||||
${tracked && part.reuse && (part.restoring ?? 0) > 0
|
||||
? html`<div class="part-waiting">
|
||||
<span class="waiting-badge">${t(`part_waiting_${part.reuse}`, L).replace("{count}", formatNumber(part.restoring!, L))}</span>
|
||||
${this.canWrite
|
||||
? html`<button class="ready-again" title=${t("part_ready_again_hint", L)} .disabled=${this._busy}
|
||||
@click=${() => this._readyAgain(part)}>
|
||||
<ha-icon icon="mdi:check"></ha-icon>${t(`part_reuse_${part.reuse}`, L)}
|
||||
</button>`
|
||||
: nothing}
|
||||
</div>`
|
||||
: nothing}
|
||||
<div class="part-meta">
|
||||
${ident ? html`<span>${ident}</span>` : nothing}
|
||||
${part.storage_location
|
||||
@@ -322,6 +362,23 @@ export class MaintenancePartsSection extends LitElement {
|
||||
${this._field(t("part_unit", L), "unit")}
|
||||
${this._field(t("part_cost", L), "cost", { type: "number" })}
|
||||
${this._field(t("part_stock", L), "stock", { type: "number" })}
|
||||
<label class="form-field">
|
||||
<span>${t("part_reuse", L)}</span>
|
||||
<select
|
||||
class="reuse-select"
|
||||
.value=${f.reuse}
|
||||
@change=${(e: Event) => {
|
||||
this._editing = { ...this._editing!, reuse: (e.target as HTMLSelectElement).value };
|
||||
}}
|
||||
>
|
||||
<option value="" ?selected=${!f.reuse}>${t("part_reuse_none", L)}</option>
|
||||
${REUSE_KINDS.map(
|
||||
(kind) => html`<option value=${kind} ?selected=${f.reuse === kind}>${t(`part_reuse_${kind}`, L)}</option>`,
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
${f.reuse ? this._field(t("part_restoring", L), "restoring", { type: "number" }) : nothing}
|
||||
${f.reuse ? html`<div class="form-hint">${t("part_reuse_hint", L)}</div>` : nothing}
|
||||
${this._field(t("part_reorder_threshold", L), "reorder_threshold", { type: "number" })}
|
||||
${this._field(t("part_package_size", L), "package_size", { type: "number" })}
|
||||
${this._field(
|
||||
@@ -477,6 +534,45 @@ export class MaintenancePartsSection extends LitElement {
|
||||
.part-meta .loc ha-icon {
|
||||
--mdc-icon-size: 13px;
|
||||
}
|
||||
.part-waiting {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.waiting-badge {
|
||||
font-size: 12px;
|
||||
padding: 1px 8px;
|
||||
border-radius: 10px;
|
||||
background: var(--secondary-background-color, #eee);
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
.ready-again {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
padding: 2px 10px;
|
||||
min-height: 28px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--primary-color);
|
||||
background: transparent;
|
||||
color: var(--primary-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
.ready-again ha-icon {
|
||||
--mdc-icon-size: 16px;
|
||||
}
|
||||
.reuse-select {
|
||||
font: inherit;
|
||||
padding: 6px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--divider-color, #ccc);
|
||||
background: var(--card-background-color, #fff);
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
.restock-input {
|
||||
width: 64px;
|
||||
padding: 4px;
|
||||
|
||||
+2
-2
@@ -12,7 +12,7 @@ import { property, state } from "lit/decorators.js";
|
||||
import { t, ensureLocale, langOf } from "../styles";
|
||||
import { runWs } from "../helpers/ws-run";
|
||||
import { openSignedDocument } from "../helpers/document-url";
|
||||
import { isSafeHttpUrl } from "../helpers/url";
|
||||
import { isSafeLink } from "../helpers/url";
|
||||
import { formatBytes } from "../helpers/format-bytes";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
@@ -145,7 +145,7 @@ export class MaintenanceStorageSectionCard extends LitElement {
|
||||
// Defense-in-depth (mirrors task-documents/documents-section): never
|
||||
// hand a non-http(s) weblink to window.open, even though the WS add
|
||||
// path already rejects such schemes.
|
||||
if (isSafeHttpUrl(doc.url)) window.open(doc.url, "_blank", "noopener");
|
||||
if (isSafeLink(doc.url)) window.open(doc.url, "_blank", "noopener");
|
||||
return;
|
||||
}
|
||||
await runWs(this, () => openSignedDocument(this.hass, doc.id), { onError: (m) => { this._error = m; } });
|
||||
|
||||
@@ -15,7 +15,7 @@ import { t, ensureLocale, langOf } from "../styles";
|
||||
import { runWs } from "../helpers/ws-run";
|
||||
import { downloadUrl } from "../helpers/download";
|
||||
import { downloadSignedDocument, openSignedDocument } from "../helpers/document-url";
|
||||
import { isSafeHttpUrl } from "../helpers/url";
|
||||
import { isSafeLink } from "../helpers/url";
|
||||
import { formatBytes } from "../helpers/format-bytes";
|
||||
import { docCategory, docDisplayName, CATEGORY_ICONS } from "../helpers/document-categories";
|
||||
import { DOC_FILTER_MIN, filterDocuments } from "../helpers/document-filter";
|
||||
@@ -152,7 +152,7 @@ export class MaintenanceTaskDocuments extends LitElement {
|
||||
// schemes, but a weblink stored before that check (or via a future
|
||||
// path) must never reach window.open as javascript:/data: — guard the
|
||||
// sink too, like documents-section does.
|
||||
if (isSafeHttpUrl(doc.url)) window.open(doc.url, "_blank", "noopener");
|
||||
if (isSafeLink(doc.url)) window.open(doc.url, "_blank", "noopener");
|
||||
return;
|
||||
}
|
||||
// A per-task page hint jumps straight to the relevant page via the PDF
|
||||
|
||||
Reference in New Issue
Block a user