93 files
This commit is contained in:
+71
@@ -167,3 +167,74 @@ describe("complete-dialog", () => {
|
||||
expect(completedEvent).to.be.false;
|
||||
});
|
||||
});
|
||||
|
||||
describe("complete-dialog cost suggestion from parts (#104 follow-up)", () => {
|
||||
async function mountWithParts(over: Partial<MaintenanceCompleteDialog> = {}) {
|
||||
const { hass } = createMockHass({});
|
||||
const el = await fixture<MaintenanceCompleteDialog>(html`
|
||||
<maintenance-complete-dialog
|
||||
.hass=${hass}
|
||||
.entryId=${"entry1"}
|
||||
.taskId=${"task1"}
|
||||
.taskName=${"Uses Parts"}
|
||||
.lang=${"en"}
|
||||
></maintenance-complete-dialog>
|
||||
`);
|
||||
Object.assign(el, over);
|
||||
el.open();
|
||||
await el.updateComplete;
|
||||
return el;
|
||||
}
|
||||
|
||||
const chip = (el: MaintenanceCompleteDialog) =>
|
||||
el.shadowRoot!.querySelector<HTMLButtonElement>(".cost-suggestion");
|
||||
|
||||
it("sums selected consumed parts (qty x unit cost) and fills on click", async () => {
|
||||
const el = await mountWithParts({
|
||||
parts: [
|
||||
{ id: "p1", name: "Filter", cost: 12.5 },
|
||||
{ id: "p2", name: "O-Ring", cost: 2.25 },
|
||||
{ id: "p3", name: "Unpriced", cost: null },
|
||||
] as never,
|
||||
consumesParts: [
|
||||
{ part_id: "p1", quantity: 1 },
|
||||
{ part_id: "p2", quantity: 2 },
|
||||
{ part_id: "p3", quantity: 1 },
|
||||
] as never,
|
||||
currencySymbol: "€",
|
||||
});
|
||||
const c = chip(el)!;
|
||||
expect(c, "suggestion chip rendered").to.exist;
|
||||
expect(c.textContent).to.include("17.00");
|
||||
expect(c.textContent).to.include("€");
|
||||
c.click();
|
||||
await el.updateComplete;
|
||||
const cost = [...el.shadowRoot!.querySelectorAll<HTMLInputElement>(".field-input")][1];
|
||||
expect(cost.value).to.equal("17.00");
|
||||
expect(chip(el), "chip hides once cost is set").to.equal(null);
|
||||
});
|
||||
|
||||
it("buy task: restock qty x unit cost, follows the qty field", async () => {
|
||||
const el = await mountWithParts({ restockDefault: 2, restockUnitCost: 4.5 });
|
||||
expect(chip(el)!.textContent).to.include("9.00");
|
||||
});
|
||||
|
||||
it("no chip when no involved part carries a price", async () => {
|
||||
const el = await mountWithParts({
|
||||
parts: [{ id: "p1", name: "Filter", cost: null }] as never,
|
||||
consumesParts: [{ part_id: "p1", quantity: 1 }] as never,
|
||||
});
|
||||
expect(chip(el)).to.equal(null);
|
||||
});
|
||||
|
||||
it("no chip once the user typed a cost themselves", async () => {
|
||||
const el = await mountWithParts({
|
||||
parts: [{ id: "p1", name: "Filter", cost: 5 }] as never,
|
||||
consumesParts: [{ part_id: "p1", quantity: 1 }] as never,
|
||||
});
|
||||
expect(chip(el)).to.exist;
|
||||
setInput(el, 1, "3.10");
|
||||
await el.updateComplete;
|
||||
expect(chip(el)).to.equal(null);
|
||||
});
|
||||
});
|
||||
|
||||
+14
-1
@@ -20,7 +20,9 @@ function setDeepLink(query: string) {
|
||||
}
|
||||
|
||||
async function settleRaf(el: { updateComplete: Promise<unknown> }) {
|
||||
// Deep-link dialog opens behind a requestAnimationFrame.
|
||||
// Deep-link dialog opens behind a requestAnimationFrame — and since the
|
||||
// dialogs became lazy code-split chunks, behind their dynamic import too.
|
||||
await customElements.whenDefined("maintenance-complete-dialog");
|
||||
await new Promise((r) => requestAnimationFrame(() => r(null)));
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
await (el as HTMLElement & { updateComplete: Promise<unknown> }).updateComplete;
|
||||
@@ -30,6 +32,15 @@ function completeDialog(el: HTMLElement): MaintenanceCompleteDialog | null {
|
||||
return sr(el).querySelector<MaintenanceCompleteDialog>("maintenance-complete-dialog");
|
||||
}
|
||||
|
||||
/** The dialogs are lazy code-split chunks — the open lands whenever the
|
||||
* whole lazy-UI group has loaded, so poll instead of guessing a delay. */
|
||||
async function waitForOpenCompleteDialog(el: HTMLElement): Promise<void> {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
if (completeDialog(el)?.shadowRoot?.querySelector("ha-dialog")) return;
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
}
|
||||
}
|
||||
|
||||
describe("panel deep links (QR scan routing)", () => {
|
||||
beforeEach(() => {
|
||||
resetTaskSeq();
|
||||
@@ -52,6 +63,7 @@ describe("panel deep links (QR scan routing)", () => {
|
||||
expect(sr(el).querySelector(".task-header"), "task detail rendered").to.exist;
|
||||
expect(sr(el).querySelector(".task-name-breadcrumb")!.textContent).to.include("Scan Me");
|
||||
// …with the complete dialog open and targeted at the scanned task.
|
||||
await waitForOpenCompleteDialog(el);
|
||||
const dlg = completeDialog(el)!;
|
||||
expect(dlg.shadowRoot!.querySelector("ha-dialog"), "complete dialog open").to.exist;
|
||||
expect(dlg.entryId).to.equal("e1");
|
||||
@@ -94,6 +106,7 @@ describe("panel deep links (QR scan routing)", () => {
|
||||
expect(
|
||||
sent.filter((m) => m.type === "maintenance_supporter/task/quick_complete").length,
|
||||
).to.equal(1);
|
||||
await waitForOpenCompleteDialog(el);
|
||||
const dlg = completeDialog(el)!;
|
||||
expect(dlg.shadowRoot!.querySelector("ha-dialog"), "fallback dialog open").to.exist;
|
||||
expect(dlg.taskName).to.equal("No Defaults");
|
||||
|
||||
+23
-7
@@ -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;
|
||||
|
||||
@@ -33,11 +33,22 @@ const common = {
|
||||
banner: { js: `/*! maintenance_supporter frontend ${manifestVersion} */` },
|
||||
};
|
||||
|
||||
// Panel
|
||||
// Panel — code-split (perf wave 2, item 5): the dialogs + settings view the
|
||||
// panel dynamic-imports land in frontend/panel-chunks/ as content-hashed
|
||||
// chunks, shrinking the entry's critical parse path. publicPath makes the
|
||||
// chunk imports ABSOLUTE, because the entry is served from a versioned
|
||||
// file URL (/maintenance_supporter_panel_<hash>) that has no directory for
|
||||
// relative imports to resolve against. Cache-safety (#124 class): the
|
||||
// entry URL changes with its content hash, and a fresh entry always names
|
||||
// exactly its own content-hashed chunks.
|
||||
rmSync("../frontend/panel-chunks", { recursive: true, force: true });
|
||||
await build({
|
||||
...common,
|
||||
entryPoints: ["maintenance-panel.ts"],
|
||||
outfile: "../frontend/maintenance-panel.js",
|
||||
outdir: "../frontend",
|
||||
splitting: true,
|
||||
chunkNames: "panel-chunks/[name]-[hash]",
|
||||
publicPath: "/maintenance_supporter_panelfiles",
|
||||
});
|
||||
|
||||
// Lovelace Card
|
||||
|
||||
@@ -16,4 +16,5 @@ export const LS_KEYS = {
|
||||
objectSort: "maintenance_supporter_object_sort",
|
||||
groupBy: "maintenance_supporter_groupby",
|
||||
objectView: "maintenance_supporter_object_view",
|
||||
objectsCache: "msp-objects-cache",
|
||||
} as const;
|
||||
|
||||
@@ -833,5 +833,7 @@
|
||||
"battery_fleet_setup_button": "Flotila baterií",
|
||||
"battery_fleet_setup_done": "Flotila baterií nastavena — jeden úkol sleduje všechny.",
|
||||
"update_banner": "Na serveru je novější verze Maintenance Supporter — načtěte znovu pro aktualizaci panelu.",
|
||||
"update_reload": "Načíst znovu"
|
||||
"update_reload": "Načíst znovu",
|
||||
"battery_fleet_forecast_overdue": "Předpovězené datum uplynulo — baterie stále hlásí dobrý stav. Pokud jste ji vyměnili, zaznamenejte výměnu; jinak byla předpověď mylná.",
|
||||
"cost_from_parts": "Použít ≈ {amount} z dílů"
|
||||
}
|
||||
|
||||
@@ -833,5 +833,7 @@
|
||||
"battery_fleet_setup_button": "Batteriflåde",
|
||||
"battery_fleet_setup_done": "Batteriflåde opsat — én opgave følger alle batterier.",
|
||||
"update_banner": "En nyere version af Maintenance Supporter er på serveren — genindlæs for at opdatere panelet.",
|
||||
"update_reload": "Genindlæs"
|
||||
"update_reload": "Genindlæs",
|
||||
"battery_fleet_forecast_overdue": "Forudsagt dato er overskredet — batteriet melder stadig god tilstand. Hvis du har skiftet det, registrér udskiftningen; ellers ramte prognosen forbi.",
|
||||
"cost_from_parts": "Brug ≈ {amount} fra dele"
|
||||
}
|
||||
|
||||
@@ -833,5 +833,7 @@
|
||||
"battery_fleet_setup_button": "Batterie-Flotte",
|
||||
"battery_fleet_setup_done": "Batterie-Flotte eingerichtet — ein Task überwacht alle Batterien.",
|
||||
"update_banner": "Auf dem Server läuft eine neuere Version von Maintenance Supporter — neu laden, um das Panel zu aktualisieren.",
|
||||
"update_reload": "Neu laden"
|
||||
"update_reload": "Neu laden",
|
||||
"battery_fleet_forecast_overdue": "Prognosedatum überschritten — die Batterie meldet sich weiterhin gesund. Falls du sie gewechselt hast, trage den Wechsel nach; andernfalls lag die Prognose daneben.",
|
||||
"cost_from_parts": "≈ {amount} aus Teilen übernehmen"
|
||||
}
|
||||
|
||||
@@ -833,5 +833,7 @@
|
||||
"battery_fleet_setup_button": "Battery fleet",
|
||||
"battery_fleet_setup_done": "Battery fleet set up — one task tracks all your batteries.",
|
||||
"update_banner": "A newer version of Maintenance Supporter is on the server — reload to update the panel.",
|
||||
"update_reload": "Reload"
|
||||
"update_reload": "Reload",
|
||||
"battery_fleet_forecast_overdue": "Predicted date passed — the battery still reports healthy. If you swapped it, record the replacement; otherwise the forecast was off.",
|
||||
"cost_from_parts": "Use ≈ {amount} from parts"
|
||||
}
|
||||
|
||||
@@ -833,5 +833,7 @@
|
||||
"battery_fleet_setup_button": "Flota de baterías",
|
||||
"battery_fleet_setup_done": "Flota de baterías configurada — una tarea controla todas.",
|
||||
"update_banner": "Hay una versión más reciente de Maintenance Supporter en el servidor — recarga para actualizar el panel.",
|
||||
"update_reload": "Recargar"
|
||||
"update_reload": "Recargar",
|
||||
"battery_fleet_forecast_overdue": "Fecha prevista superada: la batería sigue informando buen estado. Si la cambiaste, registra el reemplazo; si no, la previsión falló.",
|
||||
"cost_from_parts": "Usar ≈ {amount} de las piezas"
|
||||
}
|
||||
|
||||
@@ -833,5 +833,7 @@
|
||||
"battery_fleet_setup_button": "Paristokanta",
|
||||
"battery_fleet_setup_done": "Paristokanta määritetty — yksi tehtävä seuraa kaikkia.",
|
||||
"update_banner": "Palvelimella on uudempi Maintenance Supporter -versio — lataa uudelleen päivittääksesi paneelin.",
|
||||
"update_reload": "Lataa uudelleen"
|
||||
"update_reload": "Lataa uudelleen",
|
||||
"battery_fleet_forecast_overdue": "Ennustettu päivä on ohitettu — akku ilmoittaa yhä hyvästä kunnosta. Jos vaihdoit sen, kirjaa vaihto; muuten ennuste oli pielessä.",
|
||||
"cost_from_parts": "Käytä ≈ {amount} osista"
|
||||
}
|
||||
|
||||
@@ -833,5 +833,7 @@
|
||||
"battery_fleet_setup_button": "Parc de piles",
|
||||
"battery_fleet_setup_done": "Parc de piles configuré — une tâche suit toutes vos piles.",
|
||||
"update_banner": "Une version plus récente de Maintenance Supporter est sur le serveur — rechargez pour mettre à jour le panneau.",
|
||||
"update_reload": "Recharger"
|
||||
"update_reload": "Recharger",
|
||||
"battery_fleet_forecast_overdue": "Date prévue dépassée — la batterie se signale toujours en bon état. Si vous l'avez remplacée, enregistrez le remplacement ; sinon la prévision était erronée.",
|
||||
"cost_from_parts": "Reprendre ≈ {amount} des pièces"
|
||||
}
|
||||
|
||||
@@ -833,5 +833,7 @@
|
||||
"battery_fleet_setup_button": "बैटरी फ्लीट",
|
||||
"battery_fleet_setup_done": "बैटरी फ्लीट सेट — एक कार्य सभी बैटरियों को ट्रैक करता है।",
|
||||
"update_banner": "सर्वर पर Maintenance Supporter का नया संस्करण है — पैनल अपडेट करने के लिए पुनः लोड करें।",
|
||||
"update_reload": "पुनः लोड करें"
|
||||
"update_reload": "पुनः लोड करें",
|
||||
"battery_fleet_forecast_overdue": "अनुमानित तिथि बीत गई — बैटरी अब भी अच्छी स्थिति बता रही है। यदि आपने इसे बदला है, तो बदलाव दर्ज करें; अन्यथा पूर्वानुमान गलत था।",
|
||||
"cost_from_parts": "पुर्ज़ों से ≈ {amount} लें"
|
||||
}
|
||||
|
||||
@@ -833,5 +833,7 @@
|
||||
"battery_fleet_setup_button": "Elemflotta",
|
||||
"battery_fleet_setup_done": "Elemflotta beállítva — egyetlen feladat követi az összes elemet.",
|
||||
"update_banner": "A Maintenance Supporter újabb verziója érhető el a szerveren — töltse újra az oldalt a panel frissítéséhez.",
|
||||
"update_reload": "Újratöltés"
|
||||
"update_reload": "Újratöltés",
|
||||
"battery_fleet_forecast_overdue": "Az előrejelzett dátum elmúlt — az elem továbbra is jó állapotot jelez. Ha kicserélted, rögzítsd a cserét; különben az előrejelzés tévedett.",
|
||||
"cost_from_parts": "≈ {amount} átvétele az alkatrészekből"
|
||||
}
|
||||
|
||||
@@ -833,5 +833,7 @@
|
||||
"battery_fleet_setup_button": "Parco batterie",
|
||||
"battery_fleet_setup_done": "Parco batterie configurato — un'attività monitora tutte.",
|
||||
"update_banner": "Sul server è presente una versione più recente di Maintenance Supporter — ricarica per aggiornare il pannello.",
|
||||
"update_reload": "Ricarica"
|
||||
"update_reload": "Ricarica",
|
||||
"battery_fleet_forecast_overdue": "Data prevista superata — la batteria risulta ancora in buono stato. Se l'hai sostituita, registra la sostituzione; altrimenti la previsione era errata.",
|
||||
"cost_from_parts": "Usa ≈ {amount} dai ricambi"
|
||||
}
|
||||
|
||||
@@ -833,5 +833,7 @@
|
||||
"battery_fleet_setup_button": "電池フリート",
|
||||
"battery_fleet_setup_done": "電池フリートを設定 — 1つのタスクで全電池を管理。",
|
||||
"update_banner": "サーバーに新しいバージョンの Maintenance Supporter があります — 再読み込みしてパネルを更新してください。",
|
||||
"update_reload": "再読み込み"
|
||||
"update_reload": "再読み込み",
|
||||
"battery_fleet_forecast_overdue": "予測日を過ぎましたが、電池はまだ正常と報告しています。交換済みなら交換を記録してください。そうでなければ予測が外れています。",
|
||||
"cost_from_parts": "部品から ≈ {amount} を適用"
|
||||
}
|
||||
|
||||
@@ -833,5 +833,7 @@
|
||||
"battery_fleet_setup_button": "배터리 플릿",
|
||||
"battery_fleet_setup_done": "배터리 플릿이 설정되었습니다 — 하나의 작업이 모든 배터리를 추적합니다.",
|
||||
"update_banner": "서버에 Maintenance Supporter의 새 버전이 있습니다 — 패널을 업데이트하려면 새로 고침하세요.",
|
||||
"update_reload": "새로 고침"
|
||||
"update_reload": "새로 고침",
|
||||
"battery_fleet_forecast_overdue": "예측 날짜가 지났지만 배터리는 여전히 정상으로 보고됩니다. 교체했다면 교체를 기록하세요. 아니라면 예측이 빗나간 것입니다.",
|
||||
"cost_from_parts": "부품에서 ≈ {amount} 적용"
|
||||
}
|
||||
|
||||
@@ -833,5 +833,7 @@
|
||||
"battery_fleet_setup_button": "Batteriflåte",
|
||||
"battery_fleet_setup_done": "Batteriflåte satt opp — én oppgave følger alle batteriene.",
|
||||
"update_banner": "En nyere versjon av Maintenance Supporter er på serveren — last inn på nytt for å oppdatere panelet.",
|
||||
"update_reload": "Last inn på nytt"
|
||||
"update_reload": "Last inn på nytt",
|
||||
"battery_fleet_forecast_overdue": "Forutsagt dato er passert — batteriet melder fortsatt god tilstand. Hvis du byttet det, registrer byttet; ellers bommet prognosen.",
|
||||
"cost_from_parts": "Bruk ≈ {amount} fra deler"
|
||||
}
|
||||
|
||||
@@ -833,5 +833,7 @@
|
||||
"battery_fleet_setup_button": "Batterijvloot",
|
||||
"battery_fleet_setup_done": "Batterijvloot ingesteld — één taak volgt al je batterijen.",
|
||||
"update_banner": "Er staat een nieuwere versie van Maintenance Supporter op de server — herlaad om het paneel bij te werken.",
|
||||
"update_reload": "Herladen"
|
||||
"update_reload": "Herladen",
|
||||
"battery_fleet_forecast_overdue": "Voorspelde datum verstreken — de batterij meldt zich nog steeds gezond. Heb je hem vervangen, registreer dan de vervanging; anders zat de voorspelling ernaast.",
|
||||
"cost_from_parts": "≈ {amount} uit onderdelen overnemen"
|
||||
}
|
||||
|
||||
@@ -833,5 +833,7 @@
|
||||
"battery_fleet_setup_button": "Flota baterii",
|
||||
"battery_fleet_setup_done": "Flota baterii skonfigurowana — jedno zadanie śledzi wszystkie.",
|
||||
"update_banner": "Na serwerze jest nowsza wersja Maintenance Supporter — załaduj ponownie, aby zaktualizować panel.",
|
||||
"update_reload": "Załaduj ponownie"
|
||||
"update_reload": "Załaduj ponownie",
|
||||
"battery_fleet_forecast_overdue": "Przewidywana data minęła — bateria nadal zgłasza dobry stan. Jeśli ją wymieniono, zapisz wymianę; w przeciwnym razie prognoza była błędna.",
|
||||
"cost_from_parts": "Użyj ≈ {amount} z części"
|
||||
}
|
||||
|
||||
@@ -833,5 +833,7 @@
|
||||
"battery_fleet_setup_button": "Frota de baterias",
|
||||
"battery_fleet_setup_done": "Frota de baterias configurada — uma única tarefa acompanha todas as suas baterias.",
|
||||
"update_banner": "Há uma versão mais nova do Maintenance Supporter no servidor — recarregue para atualizar o painel.",
|
||||
"update_reload": "Recarregar"
|
||||
"update_reload": "Recarregar",
|
||||
"battery_fleet_forecast_overdue": "Data prevista ultrapassada — a bateria ainda reporta bom estado. Se você a trocou, registre a troca; caso contrário, a previsão errou.",
|
||||
"cost_from_parts": "Usar ≈ {amount} das peças"
|
||||
}
|
||||
|
||||
@@ -833,5 +833,7 @@
|
||||
"battery_fleet_setup_button": "Frota de baterias",
|
||||
"battery_fleet_setup_done": "Frota de baterias configurada — uma tarefa acompanha todas.",
|
||||
"update_banner": "Há uma versão mais recente do Maintenance Supporter no servidor — recarregue para atualizar o painel.",
|
||||
"update_reload": "Recarregar"
|
||||
"update_reload": "Recarregar",
|
||||
"battery_fleet_forecast_overdue": "Data prevista ultrapassada — a bateria continua a reportar bom estado. Se a substituiu, registe a substituição; caso contrário, a previsão falhou.",
|
||||
"cost_from_parts": "Usar ≈ {amount} das peças"
|
||||
}
|
||||
|
||||
@@ -833,5 +833,7 @@
|
||||
"battery_fleet_setup_button": "Парк батарей",
|
||||
"battery_fleet_setup_done": "Парк батарей настроен — одна задача следит за всеми.",
|
||||
"update_banner": "На сервере более новая версия Maintenance Supporter — перезагрузите, чтобы обновить панель.",
|
||||
"update_reload": "Перезагрузить"
|
||||
"update_reload": "Перезагрузить",
|
||||
"battery_fleet_forecast_overdue": "Прогнозируемая дата прошла — батарея по-прежнему сообщает о хорошем состоянии. Если вы её заменили, зафиксируйте замену; иначе прогноз оказался неверным.",
|
||||
"cost_from_parts": "Взять ≈ {amount} из запчастей"
|
||||
}
|
||||
|
||||
@@ -833,5 +833,7 @@
|
||||
"battery_fleet_setup_button": "Batteriflotta",
|
||||
"battery_fleet_setup_done": "Batteriflotta konfigurerad — en uppgift följer alla batterier.",
|
||||
"update_banner": "En nyare version av Maintenance Supporter finns på servern — ladda om för att uppdatera panelen.",
|
||||
"update_reload": "Ladda om"
|
||||
"update_reload": "Ladda om",
|
||||
"battery_fleet_forecast_overdue": "Förutsagt datum har passerat — batteriet rapporterar fortfarande god status. Om du bytte det, registrera bytet; annars slog prognosen fel.",
|
||||
"cost_from_parts": "Använd ≈ {amount} från delar"
|
||||
}
|
||||
|
||||
@@ -833,5 +833,7 @@
|
||||
"battery_fleet_setup_button": "Pil filosu",
|
||||
"battery_fleet_setup_done": "Pil filosu kuruldu — tek bir görev tüm pillerinizi takip ediyor.",
|
||||
"update_banner": "Sunucuda Maintenance Supporter'ın daha yeni bir sürümü var — paneli güncellemek için sayfayı yeniden yükleyin.",
|
||||
"update_reload": "Yeniden yükle"
|
||||
"update_reload": "Yeniden yükle",
|
||||
"battery_fleet_forecast_overdue": "Öngörülen tarih geçti — pil hâlâ sağlıklı görünüyor. Değiştirdiyseniz değişimi kaydedin; aksi halde tahmin yanılmış demektir.",
|
||||
"cost_from_parts": "Parçalardan ≈ {amount} kullan"
|
||||
}
|
||||
|
||||
@@ -833,5 +833,7 @@
|
||||
"battery_fleet_setup_button": "Парк батарей",
|
||||
"battery_fleet_setup_done": "Парк батарей налаштовано — одне завдання стежить за всіма.",
|
||||
"update_banner": "На сервері новіша версія Maintenance Supporter — перезавантажте, щоб оновити панель.",
|
||||
"update_reload": "Перезавантажити"
|
||||
"update_reload": "Перезавантажити",
|
||||
"battery_fleet_forecast_overdue": "Прогнозована дата минула — батарея й далі повідомляє про добрий стан. Якщо ви її замінили, зафіксуйте заміну; інакше прогноз не справдився.",
|
||||
"cost_from_parts": "Узяти ≈ {amount} із запчастин"
|
||||
}
|
||||
|
||||
@@ -833,5 +833,7 @@
|
||||
"battery_fleet_setup_button": "电池群",
|
||||
"battery_fleet_setup_done": "电池群已设置 — 一个任务跟踪所有电池。",
|
||||
"update_banner": "服务器上有更新版本的 Maintenance Supporter——请重新加载以更新面板。",
|
||||
"update_reload": "重新加载"
|
||||
"update_reload": "重新加载",
|
||||
"battery_fleet_forecast_overdue": "预测日期已过——电池仍报告状态良好。如果您已更换电池,请记录更换;否则说明预测有误。",
|
||||
"cost_from_parts": "采用配件合计 ≈ {amount}"
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { LitElement, html, css, nothing } from "lit";
|
||||
import { mergeSubscriptionEvent, type SubscriptionEvent } from "./helpers/subscription-merge";
|
||||
import { hydrateObjects } from "./helpers/hydrate-objects";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { sharedStyles, STATUS_COLORS, t, ensureLocale, isLocaleLoaded, setDateTimePrefs, formatDueDays } from "./styles";
|
||||
import type {
|
||||
@@ -156,10 +157,10 @@ export class MaintenanceSupporterCard extends LitElement {
|
||||
private async _loadData(): Promise<void> {
|
||||
try {
|
||||
const [objResult, statsResult] = await Promise.all([
|
||||
this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/objects" }),
|
||||
this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/objects", compact: true }),
|
||||
this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/statistics" }),
|
||||
]);
|
||||
this._objects = (objResult as { objects: MaintenanceObjectResponse[] }).objects;
|
||||
this._objects = hydrateObjects((objResult as { objects: MaintenanceObjectResponse[] }).objects);
|
||||
this._stats = statsResult as StatisticsResponse;
|
||||
} catch {
|
||||
// WS not available yet
|
||||
@@ -275,14 +276,16 @@ export class MaintenanceSupporterCard extends LitElement {
|
||||
try {
|
||||
const unsub = await this.hass.connection.subscribeMessage(
|
||||
(msg: unknown) => {
|
||||
const next = mergeSubscriptionEvent(
|
||||
this._objects,
|
||||
msg as SubscriptionEvent<MaintenanceObjectResponse>,
|
||||
);
|
||||
const ev = msg as SubscriptionEvent<MaintenanceObjectResponse>;
|
||||
// Compact payloads: hydrate before merging (helpers/hydrate-objects).
|
||||
if (ev.objects) hydrateObjects(ev.objects);
|
||||
if (ev.delta) hydrateObjects(ev.delta);
|
||||
const next = mergeSubscriptionEvent(this._objects, ev);
|
||||
if (next !== null) this._objects = next;
|
||||
},
|
||||
// deltas: only changed entries arrive — see helpers/subscription-merge.
|
||||
{ type: "maintenance_supporter/subscribe", deltas: true }
|
||||
// compact: empty keys stripped server-side, hydrated above.
|
||||
{ type: "maintenance_supporter/subscribe", deltas: true, compact: true }
|
||||
);
|
||||
// Detached mid-subscribe → drop the orphaned subscription.
|
||||
if (!this.isConnected) {
|
||||
|
||||
@@ -7,6 +7,8 @@ import { isStaleBundle } from "./helpers/bundle-version";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { sharedStyles, STATUS_COLORS, STATUS_ICONS, DEFAULT_CURRENCY_SYMBOL, t, ensureLocale, isLocaleLoaded, formatDate, formatDueDays, formatInterval, formatRecurrence, setDateTimePrefs } from "./styles";
|
||||
import { LS_KEYS } from "./helpers/storage-keys";
|
||||
import { readObjectsCache, writeObjectsCache } from "./helpers/objects-cache";
|
||||
import { hydrateObjects } from "./helpers/hydrate-objects";
|
||||
import { daysProgress } from "./helpers/interval";
|
||||
import { buildObjectReportHtml, type ReportLabels } from "./helpers/report";
|
||||
import { warrantyStatus } from "./helpers/warranty";
|
||||
@@ -34,20 +36,18 @@ import type {
|
||||
} from "./types";
|
||||
import { StatisticsService } from "./statistics-service";
|
||||
import { UserService } from "./user-service";
|
||||
import "./components/object-dialog";
|
||||
// The heavy dialogs + the settings view are NOT imported here: they are
|
||||
// esbuild code-split chunks loaded by _ensureLazyUi() right after mount
|
||||
// (roadmap perf wave 2, item 5). Only their types are imported — esbuild
|
||||
// tree-shakes type-only imports, so they cost the entry bundle nothing.
|
||||
import type { MaintenanceObjectDialog } from "./components/object-dialog";
|
||||
import "./components/documents-section";
|
||||
import "./components/parts-section";
|
||||
import "./components/task-documents";
|
||||
import "./components/task-dialog";
|
||||
import type { MaintenanceTaskDialog } from "./components/task-dialog";
|
||||
import "./components/complete-dialog";
|
||||
import type { MaintenanceCompleteDialog } from "./components/complete-dialog";
|
||||
import "./components/qr-dialog";
|
||||
import type { MaintenanceQrDialog } from "./components/qr-dialog";
|
||||
import "./components/adopt-problem-sensors-dialog";
|
||||
import type { MaintenanceAdoptProblemSensorsDialog } from "./components/adopt-problem-sensors-dialog";
|
||||
import "./components/suggested-setups-dialog";
|
||||
import "./components/battery-fleet-section";
|
||||
import type { MaintenanceSuggestedSetupsDialog } from "./components/suggested-setups-dialog";
|
||||
// v2.0.0: panel uses the extracted Calendar Card instead of its own
|
||||
@@ -61,7 +61,6 @@ import type {
|
||||
} from "./components/history-edit-dialog";
|
||||
import "./components/confirm-dialog";
|
||||
import type { MaintenanceConfirmDialog } from "./components/confirm-dialog";
|
||||
import "./components/settings-view";
|
||||
import "./components/storage-section-card";
|
||||
import "./components/seasonal-overrides-dialog";
|
||||
import type { SeasonalOverridesDialog } from "./components/seasonal-overrides-dialog";
|
||||
@@ -236,8 +235,45 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
|
||||
private _popstateHandler = (e: PopStateEvent) => this._onPopState(e);
|
||||
|
||||
/** Lazy UI chunks (perf wave 2, item 5): the six dialogs + the settings
|
||||
* view are code-split out of the entry bundle and fetched in parallel
|
||||
* right after mount — the critical parse path shrinks while every open
|
||||
* path goes through _ui(), so a click can never race a still-loading
|
||||
* chunk. */
|
||||
private _lazyUi: Promise<unknown> | null = null;
|
||||
|
||||
private _ensureLazyUi(): Promise<unknown> {
|
||||
if (!this._lazyUi) {
|
||||
this._lazyUi = Promise.all([
|
||||
import("./components/object-dialog"),
|
||||
import("./components/task-dialog"),
|
||||
import("./components/complete-dialog"),
|
||||
import("./components/qr-dialog"),
|
||||
import("./components/adopt-problem-sensors-dialog"),
|
||||
import("./components/suggested-setups-dialog"),
|
||||
import("./components/settings-view"),
|
||||
]).then(() => this.updateComplete);
|
||||
}
|
||||
return this._lazyUi;
|
||||
}
|
||||
|
||||
/** The requested dialog element, guaranteed upgraded (chunk loaded). */
|
||||
private async _ui<T extends Element>(tag: string): Promise<T | null> {
|
||||
await this._ensureLazyUi();
|
||||
return this.shadowRoot?.querySelector<T>(tag) ?? null;
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
// Prefetch the lazy UI chunks AFTER the first paint settles (idle), not
|
||||
// here: an eager prefetch put 7 fetches + ~280 KB of parsing in direct
|
||||
// competition with the initial data load and measurably slowed first
|
||||
// paint. Deep links and early clicks stay safe either way — every open
|
||||
// path awaits _ui().
|
||||
const idle = (window as unknown as { requestIdleCallback?: (cb: () => void, o?: { timeout: number }) => void }).requestIdleCallback;
|
||||
const kick = () => this._ensureLazyUi();
|
||||
if (idle) idle(kick, { timeout: 3000 });
|
||||
else window.setTimeout(kick, 1500);
|
||||
window.addEventListener("popstate", this._popstateHandler);
|
||||
window.addEventListener("keydown", this._paletteKeydown);
|
||||
window.addEventListener("resize", this._onVirtualScroll, { passive: true });
|
||||
@@ -265,6 +301,15 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
} catch {
|
||||
// storage blocked — keep the defaults
|
||||
}
|
||||
// Skeleton from cache: paint the previous visit's task list immediately;
|
||||
// the live payload replaces it through the normal _objects assignment.
|
||||
if (this._objects.length === 0) {
|
||||
const cached = readObjectsCache<MaintenanceObjectResponse, StatisticsResponse>();
|
||||
if (cached) {
|
||||
this._objects = cached.objects;
|
||||
if (cached.stats) this._stats = cached.stats;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
@@ -378,7 +423,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
|
||||
private async _loadData(): Promise<void> {
|
||||
const [objResult, statsResult, budgetResult, groupsResult, settingsResult, viewsResult] = await Promise.all([
|
||||
this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/objects" }).catch(() => null),
|
||||
this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/objects", compact: true }).catch(() => null),
|
||||
this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/statistics" }).catch(() => null),
|
||||
this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/budget_status" }).catch(() => null),
|
||||
this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/groups" }).catch(() => null),
|
||||
@@ -386,7 +431,10 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/views/list" }).catch(() => null),
|
||||
]);
|
||||
if (viewsResult) this._savedViews = (viewsResult as { views: SavedView[] }).views || [];
|
||||
if (objResult) this._objects = (objResult as { objects: MaintenanceObjectResponse[] }).objects;
|
||||
if (objResult) {
|
||||
this._objects = hydrateObjects((objResult as { objects: MaintenanceObjectResponse[] }).objects);
|
||||
writeObjectsCache(this._objects, (statsResult as StatisticsResponse | null) ?? this._stats ?? null);
|
||||
}
|
||||
// A data refresh means the open task's history may have grown (complete,
|
||||
// history edit) — the truncated list payload can't tell, so refetch.
|
||||
if (this._view === "task" && this._selectedEntryId && this._selectedTaskId) {
|
||||
@@ -470,11 +518,8 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
if (msAction === "add_object") {
|
||||
this._deepLinkHandled = true;
|
||||
cleanMsActionUrl();
|
||||
requestAnimationFrame(() => {
|
||||
this.shadowRoot
|
||||
?.querySelector<MaintenanceObjectDialog>("maintenance-object-dialog")
|
||||
?.openCreate();
|
||||
});
|
||||
void this._ui<MaintenanceObjectDialog>("maintenance-object-dialog")
|
||||
.then((d) => d?.openCreate());
|
||||
return;
|
||||
}
|
||||
if (msAction === "open_vacation"
|
||||
@@ -486,14 +531,15 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
// Switch to the Settings tab — that's where Vacation/Budget/Groups live.
|
||||
this._overviewTab = "settings";
|
||||
// Hint the settings-view which sub-section to scroll to (its own
|
||||
// settings-view component reads this on attribute change).
|
||||
requestAnimationFrame(() => {
|
||||
// settings-view component reads this on attribute change). The view is
|
||||
// a lazy chunk — wait for it before poking at the element.
|
||||
void this._ensureLazyUi().then(() => requestAnimationFrame(() => {
|
||||
const settingsView = this.shadowRoot?.querySelector(
|
||||
"maintenance-settings-view",
|
||||
) as (HTMLElement & { scrollToSection?: (s: string) => void }) | null;
|
||||
const target = msAction.replace("open_", ""); // "vacation" / "budget" / "groups" / "settings"
|
||||
settingsView?.scrollToSection?.(target);
|
||||
});
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -601,15 +647,25 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
try {
|
||||
const unsub = await this.hass.connection.subscribeMessage(
|
||||
(msg: unknown) => {
|
||||
const next = mergeSubscriptionEvent(
|
||||
this._objects,
|
||||
msg as SubscriptionEvent<MaintenanceObjectResponse>,
|
||||
);
|
||||
if (next !== null) this._objects = next;
|
||||
const ev = msg as SubscriptionEvent<MaintenanceObjectResponse>;
|
||||
// Compact payloads: hydrate incoming entries before merging so
|
||||
// everything downstream keeps seeing the full shape.
|
||||
if (ev.objects) hydrateObjects(ev.objects);
|
||||
if (ev.delta) hydrateObjects(ev.delta);
|
||||
const next = mergeSubscriptionEvent(this._objects, ev);
|
||||
if (next !== null) {
|
||||
this._objects = next;
|
||||
// Full snapshots are rare (subscribe start) — keep the skeleton
|
||||
// cache fresh from them; per-delta writes would churn storage.
|
||||
if ((msg as SubscriptionEvent<MaintenanceObjectResponse>).objects) {
|
||||
writeObjectsCache(next, this._stats ?? null);
|
||||
}
|
||||
}
|
||||
},
|
||||
// deltas: only entries whose rebuilt response actually changed —
|
||||
// no-op timer waves send nothing, a real change ships one object.
|
||||
{ type: "maintenance_supporter/subscribe", deltas: true }
|
||||
// compact: empty keys stripped server-side, hydrated above.
|
||||
{ type: "maintenance_supporter/subscribe", deltas: true, compact: true }
|
||||
);
|
||||
// If the element was detached while the subscribe was in flight, drop the
|
||||
// now-orphaned subscription instead of storing it on a dead component.
|
||||
@@ -1051,9 +1107,8 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
// --- Adopt problem sensors ---
|
||||
|
||||
private _openAdoptProblemSensors(): void {
|
||||
this.shadowRoot!
|
||||
.querySelector<MaintenanceAdoptProblemSensorsDialog>("maintenance-adopt-problem-sensors-dialog")
|
||||
?.open();
|
||||
void this._ui<MaintenanceAdoptProblemSensorsDialog>("maintenance-adopt-problem-sensors-dialog")
|
||||
.then((d) => d?.open());
|
||||
}
|
||||
|
||||
private async _onProblemSensorsAdopted(e: CustomEvent): Promise<void> {
|
||||
@@ -1069,7 +1124,8 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
const obj = this._objects.find((o) => o.entry_id === ref.entry_id);
|
||||
const tk = obj?.tasks.find((task) => task.id === ref.task_id);
|
||||
if (obj && tk) {
|
||||
this.shadowRoot!.querySelector<MaintenanceTaskDialog>("maintenance-task-dialog")?.openEdit(ref.entry_id, tk);
|
||||
void this._ui<MaintenanceTaskDialog>("maintenance-task-dialog")
|
||||
.then((d) => d?.openEdit(ref.entry_id, tk));
|
||||
}
|
||||
});
|
||||
} else {
|
||||
@@ -1100,9 +1156,8 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
}
|
||||
|
||||
private _openSuggestedSetups(): void {
|
||||
this.shadowRoot!
|
||||
.querySelector<MaintenanceSuggestedSetupsDialog>("maintenance-suggested-setups-dialog")
|
||||
?.open();
|
||||
void this._ui<MaintenanceSuggestedSetupsDialog>("maintenance-suggested-setups-dialog")
|
||||
.then((d) => d?.open());
|
||||
}
|
||||
|
||||
private _onSetupsAdopted(e: CustomEvent): void {
|
||||
@@ -1850,8 +1905,11 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
}
|
||||
|
||||
private _openCompleteDialog(entryId: string, taskId: string, taskName: string, checklist?: string[], adaptiveEnabled?: boolean): void {
|
||||
const dlg = this.shadowRoot!.querySelector<MaintenanceCompleteDialog>("maintenance-complete-dialog");
|
||||
if (!dlg) return;
|
||||
void this._ui<MaintenanceCompleteDialog>("maintenance-complete-dialog")
|
||||
.then((dlg) => dlg && this._fillAndOpenCompleteDialog(dlg, entryId, taskId, taskName, checklist, adaptiveEnabled));
|
||||
}
|
||||
|
||||
private _fillAndOpenCompleteDialog(dlg: MaintenanceCompleteDialog, entryId: string, taskId: string, taskName: string, checklist?: string[], adaptiveEnabled?: boolean): void {
|
||||
dlg.entryId = entryId;
|
||||
dlg.taskId = taskId;
|
||||
dlg.taskName = taskName;
|
||||
@@ -1873,6 +1931,10 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
const objParts = this._objects.find((o) => o.entry_id === entryId)?.parts || [];
|
||||
const refPart = tk?.part_ref ? objParts.find((pt) => pt.id === tk.part_ref!.part_id) : undefined;
|
||||
dlg.restockDefault = tk?.part_ref ? (refPart?.restock_quantity ?? 1) : null;
|
||||
// #104 follow-up: parts carry unit costs — the dialog offers their sum
|
||||
// as a one-click cost suggestion (buy task: restock qty × unit cost).
|
||||
dlg.restockUnitCost = tk?.part_ref ? (refPart?.cost ?? null) : null;
|
||||
dlg.currencySymbol = this._budget?.currency_symbol || "";
|
||||
// #111: a link may point at another object's pool — name that object, and
|
||||
// never drop a line that fails to resolve (the old .filter(Boolean) hid it).
|
||||
dlg.consumesInfo = (tk?.consumes_parts || []).map((link) =>
|
||||
@@ -1888,13 +1950,13 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
}
|
||||
|
||||
private _openQrForObject(entryId: string, objectName: string): void {
|
||||
const dlg = this.shadowRoot!.querySelector<MaintenanceQrDialog>("maintenance-qr-dialog");
|
||||
dlg?.openForObject(entryId, objectName);
|
||||
void this._ui<MaintenanceQrDialog>("maintenance-qr-dialog")
|
||||
.then((dlg) => dlg?.openForObject(entryId, objectName));
|
||||
}
|
||||
|
||||
private _openQrForTask(entryId: string, taskId: string, objectName: string, taskName: string): void {
|
||||
const dlg = this.shadowRoot!.querySelector<MaintenanceQrDialog>("maintenance-qr-dialog");
|
||||
dlg?.openForTask(entryId, taskId, objectName, taskName);
|
||||
void this._ui<MaintenanceQrDialog>("maintenance-qr-dialog")
|
||||
.then((dlg) => dlg?.openForTask(entryId, taskId, objectName, taskName));
|
||||
}
|
||||
|
||||
private _onDialogEvent = async (): Promise<void> => {
|
||||
@@ -2385,12 +2447,12 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
@click=${() => { if (this.narrow) this._actionsMenuOpen = false; }}
|
||||
>
|
||||
<ha-button
|
||||
@click=${() => this.shadowRoot!.querySelector<MaintenanceTaskDialog>("maintenance-task-dialog")?.openCreate("", this._objects)}
|
||||
@click=${() => this._ui<MaintenanceTaskDialog>("maintenance-task-dialog").then((d) => d?.openCreate("", this._objects))}
|
||||
>
|
||||
${t("new_task", L)}
|
||||
</ha-button>
|
||||
<ha-button
|
||||
@click=${() => this.shadowRoot!.querySelector<MaintenanceObjectDialog>("maintenance-object-dialog")?.openCreate()}
|
||||
@click=${() => this._ui<MaintenanceObjectDialog>("maintenance-object-dialog").then((d) => d?.openCreate())}
|
||||
>
|
||||
${t("new_object", L)}
|
||||
</ha-button>
|
||||
@@ -2422,7 +2484,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
<ha-button appearance="filled" @click=${() => this._openTemplateGallery()}>
|
||||
<ha-icon icon="mdi:view-grid-plus-outline"></ha-icon> ${t("templates_from", L)}
|
||||
</ha-button>
|
||||
<ha-button appearance="plain" @click=${() => this.shadowRoot!.querySelector<MaintenanceObjectDialog>("maintenance-object-dialog")?.openCreate()}>
|
||||
<ha-button appearance="plain" @click=${() => this._ui<MaintenanceObjectDialog>("maintenance-object-dialog").then((d) => d?.openCreate())}>
|
||||
${t("new_object", L)}
|
||||
</ha-button>
|
||||
</div>
|
||||
@@ -2720,7 +2782,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
` : nothing}
|
||||
${!isOperator ? html`
|
||||
<ha-button
|
||||
@click=${() => this.shadowRoot!.querySelector<MaintenanceObjectDialog>("maintenance-object-dialog")?.openCreate()}
|
||||
@click=${() => this._ui<MaintenanceObjectDialog>("maintenance-object-dialog").then((d) => d?.openCreate())}
|
||||
>
|
||||
${t("new_object", L)}
|
||||
</ha-button>
|
||||
@@ -3115,12 +3177,12 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
<div class="action-buttons">
|
||||
${!isOperator ? html`
|
||||
<ha-button appearance="filled" @click=${() => {
|
||||
const dlg = this.shadowRoot!.querySelector<MaintenanceTaskDialog>("maintenance-task-dialog");
|
||||
dlg?.openCreate(obj.entry_id);
|
||||
void this._ui<MaintenanceTaskDialog>("maintenance-task-dialog")
|
||||
.then((d) => d?.openCreate(obj.entry_id));
|
||||
}}>${t("add_task", L)}</ha-button>
|
||||
<ha-button appearance="plain" @click=${() => {
|
||||
const dlg = this.shadowRoot!.querySelector<MaintenanceObjectDialog>("maintenance-object-dialog");
|
||||
dlg?.openEdit(obj.entry_id, o);
|
||||
void this._ui<MaintenanceObjectDialog>("maintenance-object-dialog")
|
||||
.then((d) => d?.openEdit(obj.entry_id, o));
|
||||
}}>${t("edit", L)}</ha-button>
|
||||
` : nothing}
|
||||
<div class="more-menu-wrapper">
|
||||
@@ -3192,8 +3254,8 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
? html`<div class="empty-state-centered">
|
||||
<p class="empty">${t("no_tasks_yet", L)}</p>
|
||||
<ha-button appearance="filled" @click=${() => {
|
||||
const dlg = this.shadowRoot!.querySelector<MaintenanceTaskDialog>("maintenance-task-dialog");
|
||||
dlg?.openCreate(obj.entry_id);
|
||||
void this._ui<MaintenanceTaskDialog>("maintenance-task-dialog")
|
||||
.then((d) => d?.openCreate(obj.entry_id));
|
||||
}}>${t("add_first_task", L)}</ha-button>
|
||||
</div>`
|
||||
: html`<div class="task-table">${[...visibleTasks].sort((a, b) => {
|
||||
@@ -3372,7 +3434,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
showObject: () => this._showObject(entryId),
|
||||
toggleMoreMenu: () => this._toggleMoreMenu(),
|
||||
closeMoreMenu: () => this._closeMoreMenu(),
|
||||
openEdit: (tk) => { this.shadowRoot!.querySelector<MaintenanceTaskDialog>("maintenance-task-dialog")?.openEdit(entryId, tk); },
|
||||
openEdit: (tk) => { void this._ui<MaintenanceTaskDialog>("maintenance-task-dialog").then((d) => d?.openEdit(entryId, tk)); },
|
||||
openComplete: (tk) => this._openCompleteDialog(entryId, taskId, tk.name, this._features.checklists ? tk.checklist : undefined, this._features.adaptive && !!tk.adaptive_config?.enabled),
|
||||
promptSkip: () => this._promptSkipTask(entryId, taskId),
|
||||
toggleArchive: (archived) => this._toggleArchiveTask(entryId, taskId, archived),
|
||||
|
||||
@@ -34,6 +34,10 @@ export interface MaintenanceObject {
|
||||
/** Attached documents tagged as manuals — the fallback for the "manual"
|
||||
* column/header when documentation_url is unset. Computed server-side. */
|
||||
manual_docs?: ManualDocRef[];
|
||||
/** Battery-fleet markers (v2.53 field audit): true on THE fleet object;
|
||||
* excluded = entity_ids manually excluded from the fleet roster. */
|
||||
battery_fleet?: boolean;
|
||||
battery_fleet_excluded?: string[];
|
||||
}
|
||||
|
||||
/** Slim reference to a manual-tagged document (subset of MaintenanceDocument). */
|
||||
|
||||
Reference in New Issue
Block a user