393 files
This commit is contained in:
+3
-3
@@ -234,7 +234,7 @@ describe("complete-dialog", () => {
|
||||
expect(msg.photo_doc_ids).to.deep.equal(["doc-photo-1", "doc-photo-2", "doc-photo-3"]);
|
||||
expect("photo_doc_id" in msg).to.be.false;
|
||||
// Completing keeps the uploads — no delete goes out.
|
||||
expect(sent.some((m) => m.type === "maintenance_supporter/documents/delete")).to.be.false;
|
||||
expect(sent.some((m) => ["maintenance_supporter/documents/delete", "maintenance_supporter/documents/discard_upload"].includes(m.type))).to.be.false;
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
@@ -271,14 +271,14 @@ describe("complete-dialog", () => {
|
||||
removeButtons[0].click();
|
||||
await el.updateComplete;
|
||||
expect(el.shadowRoot!.querySelectorAll(".photo-preview img").length).to.equal(1);
|
||||
let deletes = sent.filter((m) => m.type === "maintenance_supporter/documents/delete");
|
||||
let deletes = sent.filter((m) => m.type === "maintenance_supporter/documents/discard_upload");
|
||||
expect(deletes.map((m) => m.doc_id)).to.deep.equal(["doc-photo-1"]);
|
||||
|
||||
// Cancel: the remaining upload is an orphan nobody references.
|
||||
const cancel = el.shadowRoot!.querySelector<HTMLElement>(".dialog-actions ha-button")!;
|
||||
cancel.click();
|
||||
await el.updateComplete;
|
||||
deletes = sent.filter((m) => m.type === "maintenance_supporter/documents/delete");
|
||||
deletes = sent.filter((m) => m.type === "maintenance_supporter/documents/discard_upload");
|
||||
expect(deletes.map((m) => m.doc_id)).to.deep.equal(["doc-photo-1", "doc-photo-2"]);
|
||||
expect(sent.some((m) => m.type === "maintenance_supporter/task/complete")).to.be.false;
|
||||
} finally {
|
||||
|
||||
+8
-3
@@ -144,14 +144,19 @@ describe("panel deep links (QR scan routing)", () => {
|
||||
expect(complete!.via_tag_scan).to.equal(true);
|
||||
});
|
||||
|
||||
it("a plain complete (no scan) opens the dialog WITHOUT the scan flag", async () => {
|
||||
it("the printed Complete QR (action=complete) counts as the scan", async () => {
|
||||
// action=complete URLs come ONLY from the QR generator (qr dialog, work
|
||||
// sheet, batch print) — the docs promise that scanning the printed QR
|
||||
// satisfies "Require tag scan". The 2026-08-29 version of this test
|
||||
// pinned the opposite, so a gated task refused its own sticker (bug
|
||||
// audit 2026-09-26, FE-5).
|
||||
setDeepLink("entry_id=e1&task_id=t1&action=complete");
|
||||
const { el } = await mountPanel([obj("e1", [task({ name: "Gated", require_tag_scan: true })])]);
|
||||
await settleRaf(el);
|
||||
await waitForOpenCompleteDialog(el);
|
||||
const dlg = completeDialog(el)!;
|
||||
expect(dlg.viaTagScan).to.equal(false);
|
||||
expect(dlg.shadowRoot!.querySelector(".scan-required-note"), "note shown").to.exist;
|
||||
expect(dlg.viaTagScan).to.equal(true);
|
||||
expect(dlg.shadowRoot!.querySelector(".scan-required-note"), "no 'scan required' note — this was the scan").to.equal(null);
|
||||
});
|
||||
|
||||
it("any other quick_complete refusal relays the server's message", async () => {
|
||||
|
||||
+4
-2
@@ -17,8 +17,10 @@ import { sharedStyles } from "../styles";
|
||||
import { panelStyles } from "../panel-styles";
|
||||
|
||||
const STATUSES = ["ok", "due_soon", "overdue", "triggered", "done", "archived", "paused"];
|
||||
// The task-detail view uses a SEPARATE `.status-chip` set in panel-styles.ts.
|
||||
const CHIP_STATUSES = ["ok", "warning", "overdue", "done"];
|
||||
// The task-detail view uses a SEPARATE `.status-chip` set in panel-styles.ts
|
||||
// — keyed like the pills since the audit 2026-09-26 (due_soon, not the old
|
||||
// "warning"; triggered / paused / archived had no rule at all).
|
||||
const CHIP_STATUSES = ["ok", "due_soon", "overdue", "triggered", "paused", "archived", "done"];
|
||||
|
||||
@customElement("badge-contrast-probe")
|
||||
class BadgeContrastProbe extends LitElement {
|
||||
|
||||
+8
-4
@@ -139,7 +139,8 @@ describe("task-detail renderer", () => {
|
||||
expect(host.querySelector(".task-name-breadcrumb")!.textContent).to.include("Filter Wechsel");
|
||||
expect(host.querySelector(".object-name-breadcrumb")!.textContent).to.include("Pool Pump");
|
||||
const chip = host.querySelector(".status-chip")!;
|
||||
expect(chip.classList.contains("warning")).to.be.true;
|
||||
// The status key itself (renderers/status.ts), no longer the "warning" alias.
|
||||
expect(chip.classList.contains("due_soon")).to.be.true;
|
||||
});
|
||||
|
||||
it("Complete routes to openComplete with the task; Skip to promptSkip", () => {
|
||||
@@ -163,15 +164,18 @@ describe("task-detail renderer", () => {
|
||||
expect(buttons[1].getAttribute("variant")).to.equal("warning");
|
||||
});
|
||||
|
||||
it("operator mode: only Complete/Skip buttons; menu carries just QR + worksheet", () => {
|
||||
it("operator mode: only Complete/Skip buttons; menu carries QR, worksheet and the household actions", () => {
|
||||
const host = mount(task(), ctx({ isOperator: true, moreMenuOpen: true }));
|
||||
// The ⋮ stays (QR + worksheet moved into it), but write actions don't.
|
||||
// Reset / Postpone / Snooze are READ tier on the server (any household
|
||||
// member) — hiding them was the audit 2026-09-26 finding FE-A3.
|
||||
expect(host.querySelector(".more-menu-wrapper"), "menu present").to.exist;
|
||||
const buttons = [...host.querySelectorAll(".task-header-actions ha-button")];
|
||||
expect(buttons.length).to.equal(2); // Complete + Skip only
|
||||
const items = [...host.querySelectorAll(".popup-menu-item")].map((i) => i.textContent?.trim() || "");
|
||||
expect(items.length).to.equal(2);
|
||||
expect(items.join(" ")).to.not.match(/edit|archive|delete|duplicate|reset/i);
|
||||
expect(items.length).to.equal(5);
|
||||
expect(items.join(" ")).to.match(/reset/i);
|
||||
expect(items.join(" ")).to.not.match(/edit|archive|delete|duplicate|move/i);
|
||||
});
|
||||
|
||||
it("open more-menu lists edit/qr/worksheet/duplicate/reset/postpone/snooze/archive/delete and fires callbacks", () => {
|
||||
|
||||
+5
-4
@@ -180,12 +180,13 @@ describe("maintenance-task-detail-view", () => {
|
||||
expect(el.querySelector(".detail-section")).to.equal(null);
|
||||
});
|
||||
|
||||
it("operators get the more-menu with only the read-safe items (QR + worksheet)", async () => {
|
||||
it("operators get the more-menu with only the read-tier items (QR, worksheet, reset, postpone, snooze)", async () => {
|
||||
const el = await mount(task(), ctx({ isOperator: true, moreMenuOpen: true }));
|
||||
expect(el.querySelector(".more-menu-wrapper"), "menu present for operators").to.exist;
|
||||
const labels = [...el.querySelectorAll(".popup-menu-item")].map((i) => i.textContent?.trim());
|
||||
expect(labels.length).to.equal(2);
|
||||
// No write actions leak into the operator menu.
|
||||
expect(labels.join(" ")).to.not.match(/edit|archive|delete|duplicate|reset/i);
|
||||
expect(labels.length).to.equal(5);
|
||||
// No write actions leak into the operator menu (audit 2026-09-26 FE-A3:
|
||||
// the household actions are read tier and belong in it).
|
||||
expect(labels.join(" ")).to.not.match(/edit|archive|delete|duplicate|move/i);
|
||||
});
|
||||
});
|
||||
|
||||
+176
-70
@@ -4,13 +4,21 @@
|
||||
* and turns each into a maintenance task that triggers while the problem is
|
||||
* active and clears when it resolves. A selection either attaches to a suggested
|
||||
* existing object or spins up a fresh object bound to the sensor's device.
|
||||
*
|
||||
* 2.94 (duplicates from adopting in a real home): a sensor whose device no
|
||||
* object is linked to goes by default to the existing object that device most
|
||||
* likely is (and links it); a sensor that object probably already watches
|
||||
* under another name starts unticked with that task's name; a summary says
|
||||
* per object how many tasks it has before and after. Typing another object
|
||||
* asks the server again (problem_sensors/preview).
|
||||
*/
|
||||
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
|
||||
import { t, ensureLocale, langOf } from "../styles";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { runWs } from "../helpers/ws-run";
|
||||
import { TRIGGER_FOR_MINUTES_RANGE } from "../helpers/setting-ranges";
|
||||
import { UserService } from "../user-service";
|
||||
import type { HAUser, HomeAssistant } from "../types";
|
||||
|
||||
@@ -25,8 +33,13 @@ interface ProblemSensor {
|
||||
suggested_object_name: string;
|
||||
suggested_part_id: string | null;
|
||||
suggested_part_name: string | null;
|
||||
candidate?: { entry_id: string; name: string; reasons: string[] } | null;
|
||||
target_entry_id?: string | null;
|
||||
covered_by?: Covered;
|
||||
}
|
||||
|
||||
type Covered = { task_id: string; name: string; reason: string } | null;
|
||||
|
||||
interface DiscoverResponse {
|
||||
sensors: ProblemSensor[];
|
||||
}
|
||||
@@ -52,7 +65,10 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement {
|
||||
* name on several rows makes them ONE object, an existing object's name
|
||||
* adds them to it. */
|
||||
@state() private _objectNames: Record<string, string> = {};
|
||||
@state() private _objects: Array<{ entry_id: string; name: string }> = [];
|
||||
@state() private _objects: Array<{ entry_id: string; name: string; tasks: number }> = [];
|
||||
/** 2.94: the task each sensor's target probably already has for it. */
|
||||
@state() private _covered: Record<string, Covered> = {};
|
||||
private _previewTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
@state() private _users: HAUser[] = [];
|
||||
@state() private _responsible = "";
|
||||
// #136: minutes the problem must persist before the created task triggers
|
||||
@@ -82,51 +98,113 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement {
|
||||
this._objectNames = {};
|
||||
this._responsible = "";
|
||||
this._forMinutes = "0";
|
||||
try {
|
||||
if (!this._userService) this._userService = new UserService(this.hass);
|
||||
else this._userService.updateHass(this.hass);
|
||||
const [resp, users, objs] = await Promise.all([
|
||||
this.hass.connection.sendMessagePromise<DiscoverResponse>({
|
||||
type: "maintenance_supporter/problem_sensors/discover",
|
||||
}),
|
||||
// Best-effort: adoption works fine without the user list.
|
||||
this._userService.getUsers().catch(() => [] as HAUser[]),
|
||||
// Best-effort too: the existing objects feed the name suggestions.
|
||||
this.hass.connection
|
||||
.sendMessagePromise<{ objects: Array<{ entry_id: string; object: { name: string; archived_at?: string | null } }> }>({
|
||||
type: "maintenance_supporter/objects",
|
||||
})
|
||||
.catch(() => ({ objects: [] })),
|
||||
]);
|
||||
this._objects = (objs.objects || [])
|
||||
.filter((o) => !o.object.archived_at)
|
||||
.map((o) => ({ entry_id: o.entry_id, name: o.object.name }));
|
||||
this._sensors = resp.sensors || [];
|
||||
this._selected = new Set(this._sensors.map((s) => s.entity_id));
|
||||
this._users = users;
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
if (!this._userService) this._userService = new UserService(this.hass);
|
||||
else this._userService.updateHass(this.hass);
|
||||
const [resp, users, objs] = await Promise.all([
|
||||
runWs<DiscoverResponse>(this, { type: "maintenance_supporter/problem_sensors/discover" }, {
|
||||
onError: (m) => { this._error = m; },
|
||||
}),
|
||||
// Best-effort: adoption works fine without the user list.
|
||||
this._userService.getUsers().catch(() => [] as HAUser[]),
|
||||
// Best-effort too: the existing objects feed the name suggestions.
|
||||
this.hass.connection
|
||||
.sendMessagePromise<{
|
||||
objects: Array<{ entry_id: string; object: { name: string; archived_at?: string | null }; tasks?: unknown[] }>;
|
||||
}>({
|
||||
type: "maintenance_supporter/objects",
|
||||
})
|
||||
.catch(() => ({ objects: [] })),
|
||||
]);
|
||||
this._loading = false;
|
||||
if (resp === undefined) return;
|
||||
this._objects = (objs?.objects || [])
|
||||
.filter((o) => !o.object.archived_at)
|
||||
.map((o) => ({ entry_id: o.entry_id, name: o.object.name, tasks: (o.tasks || []).length }));
|
||||
this._sensors = resp?.sensors || [];
|
||||
this._covered = Object.fromEntries(this._sensors.map((s) => [s.entity_id, s.covered_by ?? null]));
|
||||
// What the target probably already watches starts unticked.
|
||||
this._selected = new Set(this._sensors.filter((s) => !s.covered_by).map((s) => s.entity_id));
|
||||
this._users = users;
|
||||
}
|
||||
|
||||
/** The object name a row will be adopted into (typed, else the suggestion). */
|
||||
/** The default object: the one linked to the device, else the one the
|
||||
* device most likely is (2.94), else a new one named after the device. */
|
||||
private _defaultName(s: ProblemSensor): string {
|
||||
return s.suggested_entry_id ? s.suggested_object_name : (s.candidate?.name ?? s.suggested_object_name);
|
||||
}
|
||||
|
||||
/** The object name a row will be adopted into (typed, else the default). */
|
||||
private _effectiveName(s: ProblemSensor): string {
|
||||
const typed = (this._objectNames[s.entity_id] ?? "").trim();
|
||||
return typed || s.suggested_object_name;
|
||||
return typed || this._defaultName(s);
|
||||
}
|
||||
|
||||
/** An existing object the row lands in: the suggestion's entry when the
|
||||
* name is untouched, else an existing object with exactly that name. */
|
||||
/** An existing object the row lands in: the default's entry when the name
|
||||
* is untouched, else an existing object with exactly that name. */
|
||||
private _existingEntryFor(s: ProblemSensor): string | null {
|
||||
const name = this._effectiveName(s);
|
||||
if (name === s.suggested_object_name && s.suggested_entry_id) return s.suggested_entry_id;
|
||||
const defaultEntry = s.suggested_entry_id || s.target_entry_id || null;
|
||||
if (name === this._defaultName(s) && defaultEntry) return defaultEntry;
|
||||
const hit = this._objects.find((o) => o.name.trim().toLowerCase() === name.toLowerCase());
|
||||
return hit ? hit.entry_id : null;
|
||||
}
|
||||
|
||||
/** Another object typed: the server judges the hints again (debounced). */
|
||||
private _schedulePreview(): void {
|
||||
clearTimeout(this._previewTimer);
|
||||
this._previewTimer = setTimeout(() => void this._preview(), 350);
|
||||
}
|
||||
|
||||
private async _preview(): Promise<void> {
|
||||
const selections = this._sensors.map((s) => ({
|
||||
entity_id: s.entity_id,
|
||||
name: s.name,
|
||||
entry_id: this._existingEntryFor(s),
|
||||
}));
|
||||
const resp = await runWs<{ covered: Record<string, Covered> }>(
|
||||
this,
|
||||
{ type: "maintenance_supporter/problem_sensors/preview", selections },
|
||||
{ onError: () => {} },
|
||||
);
|
||||
if (resp?.covered) this._covered = { ...this._covered, ...resp.covered };
|
||||
}
|
||||
|
||||
/** Per target object: tasks before → after (only ticked rows count). */
|
||||
private _summary(L: string) {
|
||||
const groups = new Map<string, { name: string; entry: string | null; adding: number }>();
|
||||
for (const s of this._sensors) {
|
||||
if (!this._selected.has(s.entity_id)) continue;
|
||||
const entry = this._existingEntryFor(s);
|
||||
const name = this._effectiveName(s);
|
||||
const key = entry ?? `new:${name.toLowerCase()}`;
|
||||
const g = groups.get(key) ?? { name, entry, adding: 0 };
|
||||
g.adding += 1;
|
||||
groups.set(key, g);
|
||||
}
|
||||
if (groups.size === 0) return nothing;
|
||||
return html`
|
||||
<div class="summary">
|
||||
<div class="summary-title">${t("adopt_summary_title", L)}</div>
|
||||
${[...groups.values()].map((g) => {
|
||||
if (!g.entry) {
|
||||
return html`<div class="summary-line new">
|
||||
${t("setups_new_object_count", L).replace("{name}", g.name).replace("{count}", String(g.adding))}
|
||||
</div>`;
|
||||
}
|
||||
const before = this._objects.find((o) => o.entry_id === g.entry)?.tasks ?? 0;
|
||||
return html`<div class="summary-line">
|
||||
${t("adopt_summary_existing", L)
|
||||
.replace("{name}", g.name)
|
||||
.replace("{before}", String(before))
|
||||
.replace("{after}", String(before + g.adding))}
|
||||
</div>`;
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _close(): void {
|
||||
clearTimeout(this._previewTimer);
|
||||
this._open = false;
|
||||
}
|
||||
|
||||
@@ -147,40 +225,44 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement {
|
||||
|
||||
private _adopt = async (): Promise<void> => {
|
||||
if (this._selected.size === 0 || this._adopting) return;
|
||||
this._adopting = true;
|
||||
this._error = "";
|
||||
try {
|
||||
const selections = this._sensors
|
||||
.filter((s) => this._selected.has(s.entity_id))
|
||||
.map((s) => ({
|
||||
entity_id: s.entity_id,
|
||||
name: s.name,
|
||||
entry_id: this._existingEntryFor(s) ?? undefined,
|
||||
object_name: this._effectiveName(s),
|
||||
device_id: s.device_id ?? undefined,
|
||||
part_id: s.suggested_part_id ?? undefined,
|
||||
responsible_user_id: this._responsible || undefined,
|
||||
for_minutes: parseInt(this._forMinutes, 10) > 0 ? parseInt(this._forMinutes, 10) : undefined,
|
||||
}));
|
||||
const result = await this.hass.connection.sendMessagePromise<AdoptResponse>({
|
||||
type: "maintenance_supporter/problem_sensors/adopt",
|
||||
selections,
|
||||
});
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("problem-sensors-adopted", {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
detail: result,
|
||||
}),
|
||||
);
|
||||
this._open = false;
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._adopting = false;
|
||||
}
|
||||
const selections = this._sensors
|
||||
.filter((s) => this._selected.has(s.entity_id))
|
||||
.map((s) => ({
|
||||
entity_id: s.entity_id,
|
||||
name: s.name,
|
||||
entry_id: this._existingEntryFor(s) ?? undefined,
|
||||
object_name: this._effectiveName(s),
|
||||
device_id: s.device_id ?? undefined,
|
||||
part_id: s.suggested_part_id ?? undefined,
|
||||
responsible_user_id: this._responsible || undefined,
|
||||
for_minutes: parseInt(this._forMinutes, 10) > 0 ? parseInt(this._forMinutes, 10) : undefined,
|
||||
}));
|
||||
const result = await runWs<AdoptResponse>(
|
||||
this,
|
||||
{ type: "maintenance_supporter/problem_sensors/adopt", selections },
|
||||
{ busy: (b) => { this._adopting = b; }, onError: (m) => { this._error = m; } },
|
||||
);
|
||||
if (result === undefined) return;
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("problem-sensors-adopted", {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
detail: result,
|
||||
}),
|
||||
);
|
||||
this._open = false;
|
||||
};
|
||||
|
||||
/** The target line: the found object with why it was chosen, else the name. */
|
||||
private _targetLabel(s: ProblemSensor, L: string): string {
|
||||
if (s.candidate && this._existingEntryFor(s) === s.candidate.entry_id) {
|
||||
const reasons = s.candidate.reasons.map((r) => t(`setups_reason_${r}`, L)).join(", ");
|
||||
return t("setups_target_match", L).replace("{name}", s.candidate.name).replace("{reasons}", reasons);
|
||||
}
|
||||
return this._effectiveName(s);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this._open) return html``;
|
||||
const L = this._lang;
|
||||
@@ -241,19 +323,25 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement {
|
||||
class="adopt-object"
|
||||
list="adopt-object-names"
|
||||
aria-label=${t("object", L)}
|
||||
placeholder=${s.suggested_object_name}
|
||||
.value=${this._objectNames[s.entity_id] ?? s.suggested_object_name}
|
||||
placeholder=${this._defaultName(s)}
|
||||
.value=${this._objectNames[s.entity_id] ?? this._defaultName(s)}
|
||||
@input=${(e: Event) => {
|
||||
this._objectNames = { ...this._objectNames, [s.entity_id]: (e.target as HTMLInputElement).value };
|
||||
this._schedulePreview();
|
||||
}}
|
||||
/>
|
||||
</div>`
|
||||
: nothing}
|
||||
<div class="row-target">
|
||||
→ ${this._effectiveName(s)}${this._existingEntryFor(s)
|
||||
→ ${this._targetLabel(s, L)}${this._existingEntryFor(s)
|
||||
? nothing
|
||||
: html` <span class="new-tag">${t("adopt_problem_new_object", L)}</span>`}
|
||||
</div>
|
||||
${this._covered[s.entity_id]
|
||||
? html`<div class="maybe">
|
||||
${t("setups_maybe_covered", L).replace("{name}", this._covered[s.entity_id]!.name)}
|
||||
</div>`
|
||||
: nothing}
|
||||
${s.suggested_part_name
|
||||
? html`<div class="row-part">
|
||||
<ha-icon icon="mdi:package-variant-closed"></ha-icon>
|
||||
@@ -267,6 +355,8 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement {
|
||||
</div>
|
||||
`}
|
||||
|
||||
${!this._loading && this._sensors.length > 0 ? this._summary(L) : nothing}
|
||||
|
||||
${!this._loading && this._sensors.length > 0
|
||||
? html`
|
||||
<label class="responsible">
|
||||
@@ -274,8 +364,8 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement {
|
||||
<input
|
||||
class="for-input"
|
||||
type="number"
|
||||
min="0"
|
||||
max="1440"
|
||||
min=${TRIGGER_FOR_MINUTES_RANGE[0]}
|
||||
max=${TRIGGER_FOR_MINUTES_RANGE[1]}
|
||||
.value=${this._forMinutes}
|
||||
@input=${(e: Event) => (this._forMinutes = (e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
@@ -437,6 +527,22 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement {
|
||||
.new-tag {
|
||||
font-style: italic;
|
||||
}
|
||||
.maybe {
|
||||
font-size: 11px;
|
||||
color: var(--warning-color, #ff9800);
|
||||
}
|
||||
.summary {
|
||||
font-size: 12px;
|
||||
border-top: 1px dashed var(--divider-color);
|
||||
padding-top: 6px;
|
||||
}
|
||||
.summary-title {
|
||||
font-weight: 500;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.summary-line {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
.chip {
|
||||
font-size: 11px;
|
||||
padding: 1px 8px;
|
||||
|
||||
+37
-99
@@ -8,7 +8,8 @@ import { property, state } from "lit/decorators.js";
|
||||
|
||||
import { t, ensureLocale, langOf, formatDate, syncLocaleFromHass } from "../styles";
|
||||
import { LS_KEYS, lsGet, lsSet } from "../helpers/storage-keys";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { runWs } from "../helpers/ws-run";
|
||||
import { isoDateLocal } from "../helpers/calendar-bucket";
|
||||
import { px } from "../renderers/chart-utils";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
@@ -132,17 +133,27 @@ export class MaintenanceBatteryFleetSection extends LitElement {
|
||||
}
|
||||
|
||||
private async _load(): Promise<void> {
|
||||
this._loading = true;
|
||||
this._error = "";
|
||||
try {
|
||||
this._ov = await this.hass.connection.sendMessagePromise<Overview>({
|
||||
type: "maintenance_supporter/battery_fleet/overview",
|
||||
});
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
const ov = await runWs<Overview>(this, { type: "maintenance_supporter/battery_fleet/overview" }, {
|
||||
busy: (b) => { this._loading = b; },
|
||||
onError: (m) => { this._error = m; },
|
||||
});
|
||||
if (ov !== undefined) this._ov = ov;
|
||||
}
|
||||
|
||||
/** One fleet action — helpers/ws-run runWs behind the `_marking` guard,
|
||||
* with the error line and the overview reload (`onDone` runs first). */
|
||||
private async _act(payload: Record<string, unknown>, onDone?: () => void): Promise<void> {
|
||||
if (this._marking) return;
|
||||
this._error = "";
|
||||
await runWs(this, payload, {
|
||||
busy: (b) => { this._marking = b; },
|
||||
reload: async () => {
|
||||
onDone?.();
|
||||
await this._load();
|
||||
},
|
||||
onError: (m) => { this._error = m; },
|
||||
});
|
||||
}
|
||||
|
||||
private _markAll = async (): Promise<void> => {
|
||||
@@ -152,58 +163,21 @@ export class MaintenanceBatteryFleetSection extends LitElement {
|
||||
// Re-runs the idempotent setup, which restores the fleet task's trigger
|
||||
// when a user edit wiped it (issue #106) or recreates a deleted task.
|
||||
private _repair = async (): Promise<void> => {
|
||||
if (this._marking) return;
|
||||
this._marking = true;
|
||||
this._error = "";
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/battery_fleet/setup",
|
||||
language: this._lang,
|
||||
});
|
||||
await this._load();
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._marking = false;
|
||||
}
|
||||
await this._act({ type: "maintenance_supporter/battery_fleet/setup", language: this._lang });
|
||||
};
|
||||
|
||||
private async _mark(entityIds: string[] | undefined): Promise<void> {
|
||||
if (this._marking) return;
|
||||
this._marking = true;
|
||||
this._error = "";
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/battery_fleet/mark_replaced",
|
||||
...(entityIds ? { entity_ids: entityIds } : {}),
|
||||
});
|
||||
await this._load();
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._marking = false;
|
||||
}
|
||||
await this._act({
|
||||
type: "maintenance_supporter/battery_fleet/mark_replaced",
|
||||
...(entityIds ? { entity_ids: entityIds } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
// Manual exclude/include (#107): a rechargeable device the heuristics
|
||||
// missed (or any battery the user never wants tracked) leaves the fleet;
|
||||
// the restore list below the section brings it back.
|
||||
private async _setExcluded(entityId: string, excluded: boolean): Promise<void> {
|
||||
if (this._marking) return;
|
||||
this._marking = true;
|
||||
this._error = "";
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/battery_fleet/set_excluded",
|
||||
entity_id: entityId,
|
||||
excluded,
|
||||
});
|
||||
await this._load();
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._marking = false;
|
||||
}
|
||||
await this._act({ type: "maintenance_supporter/battery_fleet/set_excluded", entity_id: entityId, excluded });
|
||||
}
|
||||
|
||||
// #135: manually ADD a battery the discovery heuristics missed. The
|
||||
@@ -211,21 +185,8 @@ export class MaintenanceBatteryFleetSection extends LitElement {
|
||||
// server-side; picking an entity acts immediately (no extra button).
|
||||
private async _addBattery(e: CustomEvent<{ value?: string }>): Promise<void> {
|
||||
const entityId = e.detail?.value;
|
||||
if (!entityId || this._marking) return;
|
||||
this._marking = true;
|
||||
this._error = "";
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/battery_fleet/set_included",
|
||||
entity_id: entityId,
|
||||
included: true,
|
||||
});
|
||||
await this._load();
|
||||
} catch (e2) {
|
||||
this._error = describeWsError(e2, this._lang);
|
||||
} finally {
|
||||
this._marking = false;
|
||||
}
|
||||
if (!entityId) return;
|
||||
await this._act({ type: "maintenance_supporter/battery_fleet/set_included", entity_id: entityId, included: true });
|
||||
}
|
||||
|
||||
// #135 follow-up: fleet-wide opt-in that keeps self-charging devices
|
||||
@@ -241,20 +202,7 @@ export class MaintenanceBatteryFleetSection extends LitElement {
|
||||
}
|
||||
|
||||
private async _setFleetOption(command: "set_track_self_charging" | "set_due_without_sensor", enabled: boolean): Promise<void> {
|
||||
if (this._marking) return;
|
||||
this._marking = true;
|
||||
this._error = "";
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: `maintenance_supporter/battery_fleet/${command}`,
|
||||
enabled,
|
||||
});
|
||||
await this._load();
|
||||
} catch (e2) {
|
||||
this._error = describeWsError(e2, this._lang);
|
||||
} finally {
|
||||
this._marking = false;
|
||||
}
|
||||
await this._act({ type: `maintenance_supporter/battery_fleet/${command}`, enabled });
|
||||
}
|
||||
|
||||
/** Lazy: the recorder-backed history is fetched once, when the roster is
|
||||
@@ -380,9 +328,7 @@ export class MaintenanceBatteryFleetSection extends LitElement {
|
||||
* a direct Intl.DateTimeFormat call here ignored the profile and showed
|
||||
* 9/2/2026 to a DD/MM/YYYY user). */
|
||||
private _fmtDate(epochMs: number): string {
|
||||
const d = new Date(epochMs);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return formatDate(`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`, this._lang);
|
||||
return formatDate(isoDateLocal(new Date(epochMs)), this._lang);
|
||||
}
|
||||
|
||||
/** The grouped shopping quantities as CLICKABLE chips: a type filters the
|
||||
@@ -414,22 +360,14 @@ export class MaintenanceBatteryFleetSection extends LitElement {
|
||||
* backend (#181) so the swap also consumes the type-part cells from
|
||||
* stock, exactly like the Replaced action. */
|
||||
private async _recordJump(entityId: string, jump: { at: number; device_id: string }): Promise<void> {
|
||||
if (this._marking) return;
|
||||
this._marking = true;
|
||||
this._error = "";
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
await this._act(
|
||||
{
|
||||
type: "maintenance_supporter/battery_fleet/record_replacement",
|
||||
entity_id: entityId,
|
||||
replaced_at: new Date(jump.at * 1000).toISOString(),
|
||||
});
|
||||
this._recorded = [...this._recorded, entityId];
|
||||
await this._load();
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._marking = false;
|
||||
}
|
||||
},
|
||||
() => { this._recorded = [...this._recorded, entityId]; },
|
||||
);
|
||||
}
|
||||
|
||||
/** Purely visual level bar next to the number — scannable at a glance.
|
||||
|
||||
+27
-35
@@ -10,7 +10,7 @@ import { LitElement, html, css, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { t, syncLocaleFromHass, currencySymbolOf, langOf, formatCost, syncCurrencyDecimals} from "../styles";
|
||||
import { registerCustomCard } from "../helpers/register-card";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { runWs } from "../helpers/ws-run";
|
||||
import { sectionCardSharedStyles } from "./section-card-shared-styles";
|
||||
import type { BudgetStatus, HomeAssistant } from "../types";
|
||||
|
||||
@@ -59,43 +59,33 @@ export class MaintenanceBudgetSectionCard extends LitElement {
|
||||
}
|
||||
|
||||
private async _load(): Promise<void> {
|
||||
try {
|
||||
const r = await this.hass.connection.sendMessagePromise<BudgetStatus>({
|
||||
type: "maintenance_supporter/budget_status",
|
||||
});
|
||||
this._status = r;
|
||||
syncCurrencyDecimals(r);
|
||||
this._localMonthly = r.monthly_budget ? String(r.monthly_budget) : "";
|
||||
this._localYearly = r.yearly_budget ? String(r.yearly_budget) : "";
|
||||
this._dirty = false;
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
}
|
||||
const r = await runWs<BudgetStatus>(this, { type: "maintenance_supporter/budget_status" }, {
|
||||
onError: (m) => { this._error = m; },
|
||||
});
|
||||
if (!r) return;
|
||||
this._status = r;
|
||||
syncCurrencyDecimals(r);
|
||||
this._localMonthly = r.monthly_budget ? String(r.monthly_budget) : "";
|
||||
this._localYearly = r.yearly_budget ? String(r.yearly_budget) : "";
|
||||
this._dirty = false;
|
||||
}
|
||||
|
||||
private async _save(): Promise<void> {
|
||||
if (!this._isAdmin) return;
|
||||
this._busy = true;
|
||||
this._error = "";
|
||||
try {
|
||||
// An emptied field means "remove this budget" and must SEND 0 (the
|
||||
// backend's off-state) — omitting the key kept the old value, so a
|
||||
// budget could never be cleared from this card (bug audit 2026-08-22).
|
||||
const m = this._localMonthly.trim() === "" ? 0 : parseFloat(this._localMonthly);
|
||||
const y = this._localYearly.trim() === "" ? 0 : parseFloat(this._localYearly);
|
||||
const settings: Record<string, number> = {};
|
||||
if (!isNaN(m) && m >= 0) settings.budget_monthly = m;
|
||||
if (!isNaN(y) && y >= 0) settings.budget_yearly = y;
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/global/update",
|
||||
settings,
|
||||
});
|
||||
await this._load();
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._busy = false;
|
||||
}
|
||||
// An emptied field means "remove this budget" and must SEND 0 (the
|
||||
// backend's off-state) — omitting the key kept the old value, so a
|
||||
// budget could never be cleared from this card (bug audit 2026-08-22).
|
||||
const m = this._localMonthly.trim() === "" ? 0 : parseFloat(this._localMonthly);
|
||||
const y = this._localYearly.trim() === "" ? 0 : parseFloat(this._localYearly);
|
||||
const settings: Record<string, number> = {};
|
||||
if (!isNaN(m) && m >= 0) settings.budget_monthly = m;
|
||||
if (!isNaN(y) && y >= 0) settings.budget_yearly = y;
|
||||
await runWs(this, { type: "maintenance_supporter/global/update", settings }, {
|
||||
busy: (b) => { this._busy = b; },
|
||||
reload: () => this._load(),
|
||||
onError: (msg) => { this._error = msg; },
|
||||
});
|
||||
}
|
||||
|
||||
private _onDeepLink(): void {
|
||||
@@ -257,8 +247,10 @@ export class MaintenanceBudgetSectionCard extends LitElement {
|
||||
.bar-fill.ok { background: var(--primary-color); }
|
||||
.bar-fill.warning { background: #ff9800; }
|
||||
.bar-fill.danger { background: var(--error-color, #f44336); }
|
||||
/* minmax(0, …): a bare 1fr never shrinks below the number inputs'
|
||||
natural width — the row was 420 px wide on a phone (2026-09-27). */
|
||||
.inputs-row {
|
||||
display: grid; grid-template-columns: 1fr 1fr; gap: 8px;
|
||||
display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px;
|
||||
padding-top: 4px; border-top: 1px solid var(--divider-color);
|
||||
}
|
||||
.input-field { display: flex; flex-direction: column; gap: 4px; }
|
||||
@@ -268,7 +260,7 @@ export class MaintenanceBudgetSectionCard extends LitElement {
|
||||
}
|
||||
.input-wrap { position: relative; display: flex; align-items: center; }
|
||||
.input-wrap input {
|
||||
flex: 1; padding: 6px 32px 6px 8px; font-size: 13px;
|
||||
flex: 1; min-width: 0; width: 100%; box-sizing: border-box; padding: 6px 32px 6px 8px; font-size: 13px;
|
||||
background: var(--secondary-background-color, #2c2c2c);
|
||||
color: var(--primary-text-color);
|
||||
border: 1px solid var(--divider-color); border-radius: 6px;
|
||||
|
||||
+144
-95
@@ -4,15 +4,23 @@ import { LitElement, html, css, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import type { HomeAssistant, ReadingSlot, TaskPartLink } from "../types";
|
||||
import { lastReadingBefore, type ReadingHistoryEntry } from "../helpers/reading-slots";
|
||||
import { t, nativeFieldStyles, formatCost, formatNumber } from "../styles";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { t, nativeFieldStyles, formatCost, formatNumber, formatQty } from "../styles";
|
||||
import { runWs } from "../helpers/ws-run";
|
||||
import { isoMinuteLocal } from "../helpers/calendar-bucket";
|
||||
import { PART_QTY_RANGE, RESTOCK_QTY_RANGE } from "../helpers/setting-ranges";
|
||||
import { partLinkKey, type LinkedPart } from "../helpers/shared-parts";
|
||||
import { REQUIRED_COMPLETION_LABELS } from "./required-completion-labels";
|
||||
import { PhotoUploadController } from "../helpers/photo-upload-controller";
|
||||
import { parseDurationMinutes } from "../helpers/duration";
|
||||
import "./ms-date-field";
|
||||
import "./ms-photo-picker";
|
||||
import { photoPickerStyles } from "./ms-photo-picker";
|
||||
|
||||
/** The server's bounds (websocket USED_PARTS_FIELD quantity / task/complete
|
||||
* restock_quantity) — validated on save with a message, never dropped.
|
||||
* One TS source (helpers/setting-ranges), pinned to Python. */
|
||||
const USED_QTY_RANGE = PART_QTY_RANGE;
|
||||
|
||||
export class MaintenanceCompleteDialog extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@property() public entryId = "";
|
||||
@@ -88,6 +96,11 @@ export class MaintenanceCompleteDialog extends LitElement {
|
||||
/** 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> = {};
|
||||
/** The TYPED quantity per used part (same key). The field used to write
|
||||
* back a parsed number on every keystroke — clearing "1" to type "3"
|
||||
* snapped back to "1" first, giving "13" (bug audit 2026-09-26). The
|
||||
* text is kept as typed and parsed on save. */
|
||||
@state() private _usedQtyText: Record<string, string> = {};
|
||||
|
||||
/** #73: in-cycle ticks (keyed by item TEXT) recorded on the task detail —
|
||||
* prefill the dialog so nobody re-ticks what is already done. */
|
||||
@@ -120,6 +133,15 @@ export class MaintenanceCompleteDialog extends LitElement {
|
||||
// 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 }]));
|
||||
this._usedQtyText = {};
|
||||
}
|
||||
|
||||
/** "Value must be between {min} and {max}" with locale-formatted bounds. */
|
||||
private _rangeError(min: number, max: number): string {
|
||||
const L = this.lang;
|
||||
return t("settings_value_out_of_range", L)
|
||||
.replace("{min}", formatNumber(min, L, { maximumFractionDigits: 2 }))
|
||||
.replace("{max}", formatNumber(max, L, { maximumFractionDigits: 2 }));
|
||||
}
|
||||
|
||||
private _toggleCheck(idx: number): void {
|
||||
@@ -135,92 +157,110 @@ export class MaintenanceCompleteDialog extends LitElement {
|
||||
}
|
||||
|
||||
private async _complete(): Promise<void> {
|
||||
this._loading = true;
|
||||
// A photo still uploading would miss the completion and stay behind as
|
||||
// an orphan (bug audit 2026-09-26 #2) — the button is disabled meanwhile;
|
||||
// this guards Enter / a programmatic click as well.
|
||||
if (this._loading || this._photos.uploading) return;
|
||||
this._error = "";
|
||||
this._photos.clearError();
|
||||
try {
|
||||
const data: Record<string, unknown> = {
|
||||
type: "maintenance_supporter/task/complete",
|
||||
entry_id: this.entryId,
|
||||
task_id: this.taskId,
|
||||
};
|
||||
if (this._notes) data.notes = this._notes;
|
||||
if (this._cost) {
|
||||
const cost = parseFloat(this._cost);
|
||||
if (!isNaN(cost) && cost >= 0) data.cost = cost;
|
||||
const data: Record<string, unknown> = {
|
||||
type: "maintenance_supporter/task/complete",
|
||||
entry_id: this.entryId,
|
||||
task_id: this.taskId,
|
||||
};
|
||||
if (this._notes) data.notes = this._notes;
|
||||
if (this._cost) {
|
||||
const cost = parseFloat(this._cost);
|
||||
if (!isNaN(cost) && cost >= 0) data.cost = cost;
|
||||
}
|
||||
// Whole minutes — the server coerces to int (helpers/duration).
|
||||
const dur = parseDurationMinutes(this._duration);
|
||||
if (dur !== null) data.duration = dur;
|
||||
if (this.checklist.length > 0) {
|
||||
data.checklist_state = this._checklistState;
|
||||
}
|
||||
if (this.adaptiveEnabled) {
|
||||
data.feedback = this._feedback;
|
||||
}
|
||||
if (this._photos.photos.length > 0) {
|
||||
data.photo_doc_ids = this._photos.ids;
|
||||
}
|
||||
// Scan fallback: the backend accepts via_tag_scan on task/complete so a
|
||||
// require_tag_scan task can still be finished from the dialog the scan
|
||||
// opened. Only ever sent when a scan actually happened.
|
||||
if (this.viaTagScan) {
|
||||
data.via_tag_scan = true;
|
||||
}
|
||||
if (this._completedAt) {
|
||||
// Client-side guard mirrors the backend rule — a picked future moment
|
||||
// fails fast with a localized message instead of a WS roundtrip.
|
||||
if (new Date(this._completedAt).getTime() > Date.now()) {
|
||||
this._error = t("completed_at_future_error", this.lang);
|
||||
return;
|
||||
}
|
||||
if (this._duration) {
|
||||
const dur = parseInt(this._duration, 10);
|
||||
if (!isNaN(dur) && dur >= 0) data.duration = dur;
|
||||
// Re-add seconds if the datetime-local input drops them (same
|
||||
// normalisation as the history-edit dialog).
|
||||
data.completed_at = this._completedAt.length === 16 ? `${this._completedAt}:00` : this._completedAt;
|
||||
}
|
||||
if (this.readings.length > 0) {
|
||||
// #161 phase 2: only the meters actually read go out; the backend
|
||||
// snapshots name + unit per slot id.
|
||||
const values: Record<string, number> = {};
|
||||
for (const slot of this.readings) {
|
||||
const raw = (this._readingValues[slot.id] ?? "").trim();
|
||||
if (raw === "") continue;
|
||||
const num = parseFloat(raw.replace(",", "."));
|
||||
if (!isNaN(num)) values[slot.id] = num;
|
||||
}
|
||||
if (this.checklist.length > 0) {
|
||||
data.checklist_state = this._checklistState;
|
||||
if (Object.keys(values).length > 0) data.reading_values = values;
|
||||
} else if (this._readingValue !== "") {
|
||||
const rv = parseFloat(this._readingValue);
|
||||
if (!isNaN(rv)) data.reading_value = rv;
|
||||
}
|
||||
if (this.restockDefault !== null && this._restockQty.trim() !== "") {
|
||||
// Fractions are real purchases (0.5 kg, 2.5 l) — the old `>= 1` guard
|
||||
// dropped them silently and the part's default quantity was stocked
|
||||
// instead. Out of the server's range: say so, send nothing.
|
||||
const rq = parseFloat(this._restockQty.replace(",", "."));
|
||||
if (!Number.isFinite(rq) || rq < RESTOCK_QTY_RANGE[0] || rq > RESTOCK_QTY_RANGE[1]) {
|
||||
this._error = this._rangeError(...RESTOCK_QTY_RANGE);
|
||||
return;
|
||||
}
|
||||
if (this.adaptiveEnabled) {
|
||||
data.feedback = this._feedback;
|
||||
}
|
||||
if (this._photos.photos.length > 0) {
|
||||
data.photo_doc_ids = this._photos.ids;
|
||||
}
|
||||
// Scan fallback: the backend accepts via_tag_scan on task/complete so a
|
||||
// require_tag_scan task can still be finished from the dialog the scan
|
||||
// opened. Only ever sent when a scan actually happened.
|
||||
if (this.viaTagScan) {
|
||||
data.via_tag_scan = true;
|
||||
}
|
||||
if (this._completedAt) {
|
||||
// Client-side guard mirrors the backend rule — a picked future moment
|
||||
// fails fast with a localized message instead of a WS roundtrip.
|
||||
if (new Date(this._completedAt).getTime() > Date.now()) {
|
||||
this._error = t("completed_at_future_error", this.lang);
|
||||
this._loading = false;
|
||||
data.restock_quantity = rq;
|
||||
}
|
||||
// #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) {
|
||||
// The typed quantities, parsed now (see _usedQtyText).
|
||||
const used: TaskPartLink[] = [];
|
||||
for (const [key, link] of Object.entries(this._usedParts)) {
|
||||
const raw = this._usedQtyText[key];
|
||||
const qty = raw === undefined ? link.quantity : parseFloat(raw.replace(",", "."));
|
||||
if (!Number.isFinite(qty) || qty < USED_QTY_RANGE[0] || qty > USED_QTY_RANGE[1]) {
|
||||
this._error = this._rangeError(...USED_QTY_RANGE);
|
||||
return;
|
||||
}
|
||||
// Re-add seconds if the datetime-local input drops them (same
|
||||
// normalisation as the history-edit dialog).
|
||||
data.completed_at = this._completedAt.length === 16 ? `${this._completedAt}:00` : this._completedAt;
|
||||
used.push({ ...link, quantity: qty });
|
||||
}
|
||||
if (this.readings.length > 0) {
|
||||
// #161 phase 2: only the meters actually read go out; the backend
|
||||
// snapshots name + unit per slot id.
|
||||
const values: Record<string, number> = {};
|
||||
for (const slot of this.readings) {
|
||||
const raw = (this._readingValues[slot.id] ?? "").trim();
|
||||
if (raw === "") continue;
|
||||
const num = parseFloat(raw.replace(",", "."));
|
||||
if (!isNaN(num)) values[slot.id] = num;
|
||||
}
|
||||
if (Object.keys(values).length > 0) data.reading_values = values;
|
||||
} else if (this._readingValue !== "") {
|
||||
const rv = parseFloat(this._readingValue);
|
||||
if (!isNaN(rv)) data.reading_value = rv;
|
||||
}
|
||||
if (this.restockDefault !== null && this._restockQty !== "") {
|
||||
const rq = parseFloat(this._restockQty);
|
||||
if (!isNaN(rq) && rq >= 1) data.restock_quantity = rq;
|
||||
}
|
||||
// #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.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._photos.markAttached(); // attached now — Cancel cleanup must not touch them
|
||||
this._open = false;
|
||||
this.dispatchEvent(new CustomEvent("task-completed"));
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this.lang, t("save_error", this.lang));
|
||||
} finally {
|
||||
this._loading = false;
|
||||
data.used_parts = used
|
||||
.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 },
|
||||
);
|
||||
}
|
||||
const res = await runWs(this, data, {
|
||||
busy: (b) => { this._loading = b; },
|
||||
lang: this.lang,
|
||||
fallbackKey: "save_error",
|
||||
onError: (m) => { this._error = m; },
|
||||
});
|
||||
if (res === undefined) return;
|
||||
this._photos.markAttached(); // attached now — Cancel cleanup must not touch them
|
||||
this._open = false;
|
||||
this.dispatchEvent(new CustomEvent("task-completed"));
|
||||
}
|
||||
|
||||
/** #161 phase 2: one field per slot with the previous value as a hint;
|
||||
@@ -317,9 +357,7 @@ export class MaintenanceCompleteDialog extends LitElement {
|
||||
|
||||
/** Seed the backdate field with the current minute (local, seconds zeroed). */
|
||||
private _pickCompletedAt(): void {
|
||||
const d = new Date();
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
this._completedAt = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:00`;
|
||||
this._completedAt = isoMinuteLocal(new Date());
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -374,24 +412,33 @@ export class MaintenanceCompleteDialog extends LitElement {
|
||||
@change=${(e: Event) => {
|
||||
const next = { ...this._usedParts };
|
||||
if ((e.target as HTMLInputElement).checked) next[key] = next[key] || base;
|
||||
else delete next[key];
|
||||
else {
|
||||
delete next[key];
|
||||
const text = { ...this._usedQtyText };
|
||||
delete text[key];
|
||||
this._usedQtyText = text;
|
||||
}
|
||||
this._usedParts = next;
|
||||
}} />
|
||||
<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
|
||||
: nothing}${pt.stock !== null && pt.stock !== undefined ? ` (${formatQty(pt.stock, pt.unit, L)})` : ""}</span
|
||||
>
|
||||
</label>
|
||||
${checked
|
||||
? html`<input class="used-part-qty" type="number" min="0.01" max="999" step="0.01"
|
||||
.value=${String(link.quantity)}
|
||||
? html`<input class="used-part-qty" type="number" min=${USED_QTY_RANGE[0]} max=${USED_QTY_RANGE[1]} step="0.01"
|
||||
.value=${this._usedQtyText[key] ?? String(link.quantity)}
|
||||
@input=${(e: Event) => {
|
||||
const v = parseFloat((e.target as HTMLInputElement).value);
|
||||
this._usedParts = {
|
||||
...this._usedParts,
|
||||
[key]: { ...base, quantity: Number.isFinite(v) && v >= 0.01 ? v : 1 },
|
||||
};
|
||||
const raw = (e.target as HTMLInputElement).value;
|
||||
this._usedQtyText = { ...this._usedQtyText, [key]: raw };
|
||||
// A valid entry also feeds the live cost suggestion;
|
||||
// an empty / partial one leaves the last number
|
||||
// alone and is judged on save.
|
||||
const v = parseFloat(raw.replace(",", "."));
|
||||
if (Number.isFinite(v) && v >= USED_QTY_RANGE[0]) {
|
||||
this._usedParts = { ...this._usedParts, [key]: { ...base, quantity: v } };
|
||||
}
|
||||
}} />`
|
||||
: nothing}
|
||||
</div>`;
|
||||
@@ -406,7 +453,7 @@ export class MaintenanceCompleteDialog extends LitElement {
|
||||
? html`
|
||||
<label class="field">
|
||||
<span class="field-label">${t("restock_quantity_label", L)}</span>
|
||||
<input type="number" step="0.01" min="0.01" class="field-input"
|
||||
<input type="number" step="0.01" min=${RESTOCK_QTY_RANGE[0]} max=${RESTOCK_QTY_RANGE[1]} class="field-input"
|
||||
.value=${this._restockQty}
|
||||
@input=${(e: Event) => (this._restockQty = (e.target as HTMLInputElement).value)} />
|
||||
</label>`
|
||||
@@ -432,7 +479,7 @@ export class MaintenanceCompleteDialog extends LitElement {
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">${t("duration_minutes", L)}${this._req("duration")}</span>
|
||||
<input type="number" step="0.01" min="0" class="field-input"
|
||||
<input type="number" step="1" min="0" inputmode="numeric" class="field-input"
|
||||
.value=${this._duration}
|
||||
@input=${(e: Event) => (this._duration = (e.target as HTMLInputElement).value)} />
|
||||
</label>
|
||||
@@ -495,8 +542,10 @@ export class MaintenanceCompleteDialog extends LitElement {
|
||||
</ha-button>
|
||||
<ha-button
|
||||
@click=${this._complete}
|
||||
.disabled=${this._loading || this._missingRequired.length > 0}
|
||||
title=${this._missingRequired.length
|
||||
.disabled=${this._loading || this._photos.uploading || this._missingRequired.length > 0}
|
||||
title=${this._photos.uploading
|
||||
? t("uploading", L)
|
||||
: this._missingRequired.length
|
||||
? this._missingRequired.map((f) => t("err_required", L).replace("{field}", t(REQUIRED_COMPLETION_LABELS[f] ?? f, L))).join(" · ")
|
||||
: ""}
|
||||
>
|
||||
|
||||
+93
-70
@@ -11,11 +11,11 @@ import { LitElement, html, css, nothing } from "lit";
|
||||
import { isSafeHttpUrl } from "../helpers/url";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { t, ensureLocale, langOf } from "../styles";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { runWs } from "../helpers/ws-run";
|
||||
import { downloadUrl } from "../helpers/download";
|
||||
import { downloadSignedDocument, openSignedDocument, signDocumentPath } from "../helpers/document-url";
|
||||
import { formatBytes } from "../helpers/format-bytes";
|
||||
import { docDisplayName, CATEGORIES, CATEGORY_ICONS } from "../helpers/document-categories";
|
||||
import { docCategory, docDisplayName, CATEGORIES, CATEGORY_ICONS } from "../helpers/document-categories";
|
||||
import { DOC_FILTER_MIN, DOC_SORT_MODES, asDocSortMode, filterDocuments, sortDocuments, type DocSortMode } from "../helpers/document-filter";
|
||||
import { LS_KEYS, lsGet, lsSet } from "../helpers/storage-keys";
|
||||
import type { HomeAssistant } from "../types";
|
||||
@@ -66,6 +66,10 @@ export class MaintenanceDocumentsSection extends LitElement {
|
||||
@state() private _dragOver = false;
|
||||
|
||||
private _loadedFor: string | null = null;
|
||||
/** Only the newest list fetch may land (bug audit 2026-09-26): the panel
|
||||
* REUSES this element when the user moves to another object, and a slow
|
||||
* answer for the previous object overwrote the new one's list. */
|
||||
private _loadSeq = 0;
|
||||
private _localeReady = false;
|
||||
|
||||
private _isImage(doc: MaintenanceDocument): boolean {
|
||||
@@ -89,34 +93,80 @@ export class MaintenanceDocumentsSection extends LitElement {
|
||||
void ensureLocale(this._lang).then(() => this.requestUpdate());
|
||||
}
|
||||
if (this.hass && this.entryId && this._loadedFor !== this.entryId) {
|
||||
const switched = this._loadedFor !== null;
|
||||
this._loadedFor = this.entryId;
|
||||
if (switched) this._resetForObject();
|
||||
void this._load();
|
||||
}
|
||||
}
|
||||
|
||||
/** Another object in the same element: nothing of the previous one may
|
||||
* survive — its list, thumbnails, an open link / edit form (whose Save
|
||||
* would have written to the NEW object) or a message. */
|
||||
private _resetForObject(): void {
|
||||
this._docs = [];
|
||||
this._loaded = false;
|
||||
this._thumbs = {};
|
||||
this._filter = "";
|
||||
this._error = "";
|
||||
this._hint = "";
|
||||
this._addingLink = false;
|
||||
this._linkUrl = "";
|
||||
this._linkTitle = "";
|
||||
this._linkDescription = "";
|
||||
this._editingId = "";
|
||||
this._lightboxUrl = "";
|
||||
}
|
||||
|
||||
private async _load(): Promise<void> {
|
||||
try {
|
||||
const r = await this.hass.connection.sendMessagePromise<{ documents: MaintenanceDocument[] }>({
|
||||
type: "maintenance_supporter/documents/list",
|
||||
entry_id: this.entryId,
|
||||
});
|
||||
this._docs = r.documents || [];
|
||||
this._loaded = true;
|
||||
this._error = "";
|
||||
this._thumbs = {};
|
||||
void this._loadThumbs();
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
this._loaded = true;
|
||||
const entryId = this.entryId;
|
||||
const seq = ++this._loadSeq;
|
||||
const stale = () => seq !== this._loadSeq || entryId !== this.entryId;
|
||||
let failure = "";
|
||||
const r = await runWs<{ documents: MaintenanceDocument[] }>(
|
||||
this,
|
||||
{ type: "maintenance_supporter/documents/list", entry_id: entryId },
|
||||
{ onError: (m) => { failure = m; } },
|
||||
);
|
||||
if (stale()) return;
|
||||
this._loaded = true;
|
||||
if (r === undefined) {
|
||||
this._error = failure;
|
||||
return;
|
||||
}
|
||||
this._docs = r?.documents || [];
|
||||
this._error = "";
|
||||
this._thumbs = {};
|
||||
void this._loadThumbs(stale);
|
||||
}
|
||||
|
||||
/** A document write — helpers/ws-run runWs with the busy flag, the error
|
||||
* line and the list reload (`onDone` runs first). */
|
||||
private async _write(payload: Record<string, unknown>, onDone?: () => void, fallbackKey?: string): Promise<void> {
|
||||
this._error = "";
|
||||
await runWs(this, payload, {
|
||||
busy: (b) => { this._busy = b; },
|
||||
fallbackKey,
|
||||
reload: async () => {
|
||||
onDone?.();
|
||||
await this._load();
|
||||
},
|
||||
onError: (m) => { this._error = m; },
|
||||
});
|
||||
}
|
||||
|
||||
/** The signed open / download helpers, their failure on the error line. */
|
||||
private async _signed(call: () => Promise<unknown>): Promise<void> {
|
||||
await runWs(this, call, { onError: (m) => { this._error = m; } });
|
||||
}
|
||||
|
||||
/** Pre-sign a serve URL for each image doc so it can render as a thumbnail. */
|
||||
private async _loadThumbs(): Promise<void> {
|
||||
private async _loadThumbs(stale: () => boolean = () => false): Promise<void> {
|
||||
await Promise.all(
|
||||
this._docs.filter((d) => this._isImage(d)).map(async (d) => {
|
||||
try {
|
||||
const url = await this._sign(d);
|
||||
if (stale()) return;
|
||||
this._thumbs = { ...this._thumbs, [d.id]: url };
|
||||
} catch {
|
||||
/* leave the fallback icon */
|
||||
@@ -126,8 +176,7 @@ export class MaintenanceDocumentsSection extends LitElement {
|
||||
}
|
||||
|
||||
private _category_of(doc: MaintenanceDocument): string {
|
||||
const tag = (doc.tags || []).find((x) => (CATEGORIES as readonly string[]).includes(x));
|
||||
return tag || "other";
|
||||
return docCategory(doc);
|
||||
}
|
||||
|
||||
/** Keyboard support for the file-picker <label>s (Enter/Space → open). */
|
||||
@@ -173,6 +222,9 @@ export class MaintenanceDocumentsSection extends LitElement {
|
||||
|
||||
private async _uploadFiles(files: File[], category?: string): Promise<void> {
|
||||
const cat = category ?? this._category;
|
||||
// Captured: the element may be moved to another object mid-upload — the
|
||||
// remaining files still belong to the object the user dropped them on.
|
||||
const entryId = this.entryId;
|
||||
this._busy = true;
|
||||
this._error = "";
|
||||
this._hint = "";
|
||||
@@ -184,7 +236,7 @@ export class MaintenanceDocumentsSection extends LitElement {
|
||||
for (const file of files) {
|
||||
let doc;
|
||||
try {
|
||||
doc = await uploadDocument(this.hass, this.entryId, file, [cat]);
|
||||
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;
|
||||
@@ -194,6 +246,7 @@ export class MaintenanceDocumentsSection extends LitElement {
|
||||
if (doc.duplicate_in_object) dupInObject++;
|
||||
else if (doc.deduped) deduped++;
|
||||
}
|
||||
if (entryId !== this.entryId) return; // the list shown is another object's now
|
||||
if (dupInObject) this._hint = t("doc_dup_in_object", this._lang);
|
||||
else if (deduped) this._hint = t("doc_deduped", this._lang);
|
||||
await this._load();
|
||||
@@ -205,11 +258,7 @@ export class MaintenanceDocumentsSection extends LitElement {
|
||||
}
|
||||
|
||||
private async _download(doc: MaintenanceDocument): Promise<void> {
|
||||
try {
|
||||
await downloadSignedDocument(this.hass, doc.id, doc.filename || doc.title || "document");
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
}
|
||||
await this._signed(() => downloadSignedDocument(this.hass, doc.id, doc.filename || doc.title || "document"));
|
||||
}
|
||||
|
||||
/** Open a document for viewing: images in an in-app lightbox, everything else
|
||||
@@ -219,11 +268,7 @@ export class MaintenanceDocumentsSection extends LitElement {
|
||||
this._lightboxUrl = this._thumbs[doc.id] || (await this._sign(doc));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await openSignedDocument(this.hass, doc.id);
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
}
|
||||
await this._signed(() => openSignedDocument(this.hass, doc.id));
|
||||
}
|
||||
|
||||
/** Open a document from a title/row click (not just the small icons): preview
|
||||
@@ -259,66 +304,44 @@ export class MaintenanceDocumentsSection extends LitElement {
|
||||
(x) => !(CATEGORIES as readonly string[]).includes(x),
|
||||
);
|
||||
const tags = doc.kind === "file" ? [this._editCategory, ...freeTags] : (doc.tags ?? []);
|
||||
this._busy = true;
|
||||
this._error = "";
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
await this._write(
|
||||
{
|
||||
type: "maintenance_supporter/documents/update",
|
||||
doc_id: doc.id,
|
||||
title: this._editTitle.trim() || doc.filename || doc.url || "",
|
||||
tags,
|
||||
description: this._editDescription.trim(),
|
||||
});
|
||||
this._editingId = "";
|
||||
await this._load();
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._busy = false;
|
||||
}
|
||||
},
|
||||
() => { this._editingId = ""; },
|
||||
);
|
||||
}
|
||||
|
||||
private async _delete(doc: MaintenanceDocument): Promise<void> {
|
||||
const name = docDisplayName(doc);
|
||||
if (!window.confirm(t("doc_delete_confirm", this._lang).replace("{name}", name))) return;
|
||||
this._busy = true;
|
||||
this._error = "";
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/documents/delete",
|
||||
doc_id: doc.id,
|
||||
});
|
||||
await this._load();
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._busy = false;
|
||||
}
|
||||
await this._write({ type: "maintenance_supporter/documents/delete", doc_id: doc.id });
|
||||
}
|
||||
|
||||
private async _addLink(): Promise<void> {
|
||||
const url = this._linkUrl.trim();
|
||||
if (!url) return;
|
||||
this._busy = true;
|
||||
this._error = "";
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
const entryId = this.entryId;
|
||||
await this._write(
|
||||
{
|
||||
type: "maintenance_supporter/documents/add_link",
|
||||
entry_id: this.entryId,
|
||||
entry_id: entryId,
|
||||
url,
|
||||
title: this._linkTitle.trim() || null,
|
||||
description: this._linkDescription.trim() || null,
|
||||
});
|
||||
this._linkUrl = "";
|
||||
this._linkTitle = "";
|
||||
this._linkDescription = "";
|
||||
this._addingLink = false;
|
||||
await this._load();
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang, t("doc_link_invalid", this._lang));
|
||||
} finally {
|
||||
this._busy = false;
|
||||
}
|
||||
},
|
||||
() => {
|
||||
this._linkUrl = "";
|
||||
this._linkTitle = "";
|
||||
this._linkDescription = "";
|
||||
this._addingLink = false;
|
||||
},
|
||||
"doc_link_invalid",
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
|
||||
import { t, langOf } from "../styles";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { runWs } from "../helpers/ws-run";
|
||||
import "./ms-textfield";
|
||||
import type {
|
||||
GroupTaskRef,
|
||||
@@ -76,33 +76,29 @@ export class MaintenanceGroupDialog extends LitElement {
|
||||
this._error = t("group_name_required", this._lang);
|
||||
return;
|
||||
}
|
||||
this._loading = true;
|
||||
this._error = "";
|
||||
try {
|
||||
const task_refs = this._buildTaskRefs();
|
||||
if (this._groupId) {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/group/update",
|
||||
group_id: this._groupId,
|
||||
name,
|
||||
description: this._description,
|
||||
task_refs,
|
||||
});
|
||||
} else {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/group/create",
|
||||
name,
|
||||
description: this._description,
|
||||
task_refs,
|
||||
});
|
||||
}
|
||||
this._open = false;
|
||||
this.dispatchEvent(new CustomEvent("group-saved"));
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang, t("save_error", this._lang));
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
const task_refs = this._buildTaskRefs();
|
||||
const res = await runWs(
|
||||
this,
|
||||
this._groupId
|
||||
? {
|
||||
type: "maintenance_supporter/group/update",
|
||||
group_id: this._groupId,
|
||||
name,
|
||||
description: this._description,
|
||||
task_refs,
|
||||
}
|
||||
: {
|
||||
type: "maintenance_supporter/group/create",
|
||||
name,
|
||||
description: this._description,
|
||||
task_refs,
|
||||
},
|
||||
{ busy: (b) => { this._loading = b; }, fallbackKey: "save_error", onError: (m) => { this._error = m; } },
|
||||
);
|
||||
if (res === undefined) return;
|
||||
this._open = false;
|
||||
this.dispatchEvent(new CustomEvent("group-saved"));
|
||||
};
|
||||
|
||||
render() {
|
||||
|
||||
+49
-59
@@ -10,8 +10,11 @@ import { LitElement, html, css, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { t, ensureLocale, langOf } from "../styles";
|
||||
import { registerCustomCard } from "../helpers/register-card";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { runWs } from "../helpers/ws-run";
|
||||
import { sectionCardSharedStyles } from "./section-card-shared-styles";
|
||||
import { fetchSettingsOnce } from "../helpers/settings-cache";
|
||||
import { canWrite, NO_DELEGATION, type WriteAccess } from "../helpers/permissions";
|
||||
import { confirmAction } from "../helpers/confirm";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
interface GroupEntry {
|
||||
@@ -36,6 +39,8 @@ export class MaintenanceGroupsSectionCard extends LitElement {
|
||||
@state() private _newName = "";
|
||||
@state() private _editingId: string | null = null;
|
||||
@state() private _editingName = "";
|
||||
/** Operator-write delegation — group CRUD is write tier (canWrite). */
|
||||
@state() private _access: WriteAccess = NO_DELEGATION;
|
||||
|
||||
private _hasInitiallyLoaded = false;
|
||||
|
||||
@@ -51,8 +56,12 @@ export class MaintenanceGroupsSectionCard extends LitElement {
|
||||
return langOf(this.hass);
|
||||
}
|
||||
|
||||
private get _isAdmin(): boolean {
|
||||
return (this.hass?.user?.is_admin ?? true) as boolean;
|
||||
/** Group create / rename / delete are WRITE tier on the server: admins,
|
||||
* plus allowlisted operators with delegation on — the panel's rule
|
||||
* (helpers/permissions). `is_admin` alone hid the controls from a
|
||||
* delegated operator (DRY audit 2026-09-26). */
|
||||
private get _canWrite(): boolean {
|
||||
return canWrite(this.hass?.user, this._access);
|
||||
}
|
||||
|
||||
updated(changedProps: Map<string, unknown>): void {
|
||||
@@ -60,40 +69,39 @@ export class MaintenanceGroupsSectionCard extends LitElement {
|
||||
if (changedProps.has("hass") && this.hass && !this._hasInitiallyLoaded) {
|
||||
this._hasInitiallyLoaded = true;
|
||||
void this._load();
|
||||
void fetchSettingsOnce(this.hass).then((s) => { this._access = s.access; });
|
||||
void ensureLocale(this._lang).then(() => this.requestUpdate());
|
||||
}
|
||||
}
|
||||
|
||||
private async _load(): Promise<void> {
|
||||
try {
|
||||
const r = await this.hass.connection.sendMessagePromise<GroupsResp>({
|
||||
type: "maintenance_supporter/groups",
|
||||
});
|
||||
this._groups = r.groups || {};
|
||||
this._loaded = true;
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
}
|
||||
const r = await runWs<GroupsResp>(this, { type: "maintenance_supporter/groups" }, {
|
||||
onError: (m) => { this._error = m; },
|
||||
});
|
||||
if (r === undefined) return;
|
||||
this._groups = r?.groups || {};
|
||||
this._loaded = true;
|
||||
}
|
||||
|
||||
/** One group mutation — helpers/ws-run runWs with the busy flag, the
|
||||
* error line and the list reload (`onDone` runs first). */
|
||||
private async _act(payload: Record<string, unknown>, onDone?: () => void): Promise<void> {
|
||||
this._error = "";
|
||||
await runWs(this, payload, {
|
||||
busy: (b) => { this._busy = b; },
|
||||
reload: async () => {
|
||||
onDone?.();
|
||||
await this._load();
|
||||
},
|
||||
onError: (m) => { this._error = m; },
|
||||
});
|
||||
}
|
||||
|
||||
private async _addGroup(): Promise<void> {
|
||||
if (!this._isAdmin) return;
|
||||
if (!this._canWrite) return;
|
||||
const name = this._newName.trim();
|
||||
if (!name) return;
|
||||
this._busy = true;
|
||||
this._error = "";
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/group/create",
|
||||
name,
|
||||
});
|
||||
this._newName = "";
|
||||
await this._load();
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._busy = false;
|
||||
}
|
||||
await this._act({ type: "maintenance_supporter/group/create", name }, () => { this._newName = ""; });
|
||||
}
|
||||
|
||||
private _startEdit(id: string): void {
|
||||
@@ -102,43 +110,25 @@ export class MaintenanceGroupsSectionCard extends LitElement {
|
||||
}
|
||||
|
||||
private async _saveEdit(): Promise<void> {
|
||||
if (!this._isAdmin || !this._editingId) return;
|
||||
if (!this._canWrite || !this._editingId) return;
|
||||
const name = this._editingName.trim();
|
||||
if (!name) return;
|
||||
this._busy = true;
|
||||
this._error = "";
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/group/update",
|
||||
group_id: this._editingId,
|
||||
name,
|
||||
});
|
||||
await this._act({ type: "maintenance_supporter/group/update", group_id: this._editingId, name }, () => {
|
||||
this._editingId = null;
|
||||
this._editingName = "";
|
||||
await this._load();
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._busy = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async _deleteGroup(id: string, name: string): Promise<void> {
|
||||
if (!this._isAdmin) return;
|
||||
const confirmText = t("group_delete_confirm", this._lang).replace("{name}", name);
|
||||
if (!window.confirm(confirmText)) return;
|
||||
this._busy = true;
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/group/delete",
|
||||
group_id: id,
|
||||
});
|
||||
await this._load();
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._busy = false;
|
||||
}
|
||||
if (!this._canWrite) return;
|
||||
const ok = await confirmAction(this.hass, {
|
||||
title: t("delete", this._lang),
|
||||
message: t("delete_group_confirm", this._lang).replace("{name}", name),
|
||||
confirmText: t("delete", this._lang),
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
await this._act({ type: "maintenance_supporter/group/delete", group_id: id });
|
||||
}
|
||||
|
||||
private _onDeepLink(): void {
|
||||
@@ -210,7 +200,7 @@ export class MaintenanceGroupsSectionCard extends LitElement {
|
||||
: html`
|
||||
<span class="group-name">${g.name || "Unnamed"}</span>
|
||||
<span class="task-count">${taskCount}</span>
|
||||
${this._isAdmin
|
||||
${this._canWrite
|
||||
? html`
|
||||
<button class="icon-btn"
|
||||
title="${t("edit", L)}"
|
||||
@@ -233,7 +223,7 @@ export class MaintenanceGroupsSectionCard extends LitElement {
|
||||
</div>
|
||||
`}
|
||||
|
||||
${this._isAdmin
|
||||
${this._canWrite
|
||||
? html`
|
||||
<div class="add-row">
|
||||
<input type="text"
|
||||
|
||||
+146
-132
@@ -11,8 +11,12 @@ import { LitElement, html, css, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { t, langOf, formatNumber } from "../styles";
|
||||
import type { HomeAssistant, ReadingSlot, ReadingValue } from "../types";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { runWs } from "../helpers/ws-run";
|
||||
import { PART_QTY_RANGE } from "../helpers/setting-ranges";
|
||||
import { focusModalShell, modalShellStyles, renderModalShell } from "../helpers/modal-shell";
|
||||
import { PhotoUploadController } from "../helpers/photo-upload-controller";
|
||||
import { parseDurationMinutes } from "../helpers/duration";
|
||||
import { confirmAction } from "../helpers/confirm";
|
||||
import "./ms-date-field";
|
||||
import "./history-photo";
|
||||
import "./ms-photo-picker";
|
||||
@@ -76,6 +80,12 @@ export class MaintenanceHistoryEditDialog extends LitElement {
|
||||
@state() private _partOptions: PartOption[] | null = null;
|
||||
@state() private _partQty: Record<string, number> = {};
|
||||
private _partQtyOriginal = "";
|
||||
/** Bumped by every openEdit() and close(): an async answer started under
|
||||
* an older value belongs to a dialog session that is gone. The draft's
|
||||
* identity could not tell — every keystroke replaces `_draft` (`_set`),
|
||||
* so typing before parts/overview answered dropped the parts section
|
||||
* and the save skipped used_parts (bug audit 2026-09-26 #2). */
|
||||
private _openGen = 0;
|
||||
|
||||
// #161: the edited photo list (seeded with the entry's photos; the docs
|
||||
// THIS session uploaded are dropped again by an abandoned edit, a removed
|
||||
@@ -97,6 +107,7 @@ export class MaintenanceHistoryEditDialog extends LitElement {
|
||||
* pass `original_timestamp` (the entry's current timestamp before edit)
|
||||
* so the backend can find the entry. */
|
||||
public openEdit(draft: HistoryEntryDraft): void {
|
||||
this._openGen++;
|
||||
this._draft = { ...draft };
|
||||
this._originalSnapshot = { ...draft };
|
||||
this._error = "";
|
||||
@@ -147,8 +158,11 @@ export class MaintenanceHistoryEditDialog extends LitElement {
|
||||
/** The object's own parts + pooled parts this task draws on — from the
|
||||
* instance-wide overview so pooled owners resolve without extra calls. */
|
||||
private async _loadPartOptions(): Promise<void> {
|
||||
// The entry as opened: entry_id / task_id / used_parts never change
|
||||
// while the dialog is open (only the edited fields do).
|
||||
const draft = this._draft;
|
||||
if (!draft) return;
|
||||
const gen = this._openGen;
|
||||
try {
|
||||
const result = await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/parts/overview",
|
||||
@@ -158,6 +172,11 @@ export class MaintenanceHistoryEditDialog extends LitElement {
|
||||
consumers: Array<{ entry_id: string; task_id: string }>;
|
||||
}>;
|
||||
};
|
||||
// The dialog was closed or reopened for ANOTHER entry while this was in
|
||||
// flight: its options (and quantities) belong to that other entry now
|
||||
// (bug audit 2026-09-26 — out-of-order answers). Edits typed in the
|
||||
// meantime do NOT count as another session.
|
||||
if (gen !== this._openGen) return;
|
||||
const options: PartOption[] = [];
|
||||
for (const row of result.parts || []) {
|
||||
const own = row.entry_id === draft.entry_id;
|
||||
@@ -193,6 +212,7 @@ export class MaintenanceHistoryEditDialog extends LitElement {
|
||||
this._partQty = qty;
|
||||
this._partQtyOriginal = this._partSelectionKey();
|
||||
} catch {
|
||||
if (gen !== this._openGen) return;
|
||||
this._partOptions = []; // parts UI unavailable — the rest still edits
|
||||
}
|
||||
}
|
||||
@@ -206,6 +226,7 @@ export class MaintenanceHistoryEditDialog extends LitElement {
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
this._openGen++;
|
||||
this._open = false;
|
||||
this._error = "";
|
||||
this._draft = null;
|
||||
@@ -227,115 +248,124 @@ export class MaintenanceHistoryEditDialog extends LitElement {
|
||||
private async _delete(): Promise<void> {
|
||||
if (!this._draft || !this._originalSnapshot) return;
|
||||
const L = this._lang;
|
||||
if (!window.confirm(t("history_delete_confirm", L))) return;
|
||||
this._saving = true;
|
||||
// The confirm dialog, not the browser's native modal — this dialog is
|
||||
// also mounted on dashboards (DRY audit 2026-09-26).
|
||||
const confirmed = await confirmAction(this.hass, {
|
||||
title: t("history_delete_entry", L),
|
||||
message: t("history_delete_confirm", L),
|
||||
confirmText: t("delete", L),
|
||||
danger: true,
|
||||
});
|
||||
if (!confirmed || !this._draft || !this._originalSnapshot) return;
|
||||
const draft = this._draft;
|
||||
this._error = "";
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/task/history/delete",
|
||||
entry_id: this._draft.entry_id,
|
||||
task_id: this._draft.task_id,
|
||||
timestamp: this._originalSnapshot.original_timestamp,
|
||||
});
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("history-entry-saved", {
|
||||
detail: { entry_id: this._draft.entry_id, task_id: this._draft.task_id, deleted: true },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
this.close();
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, L);
|
||||
} finally {
|
||||
this._saving = false;
|
||||
}
|
||||
const ok = await this._runWs({
|
||||
type: "maintenance_supporter/task/history/delete",
|
||||
entry_id: draft.entry_id,
|
||||
task_id: draft.task_id,
|
||||
timestamp: this._originalSnapshot.original_timestamp,
|
||||
});
|
||||
if (ok === undefined) return;
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("history-entry-saved", {
|
||||
detail: { entry_id: draft.entry_id, task_id: draft.task_id, deleted: true },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
this.close();
|
||||
}
|
||||
|
||||
/** helpers/ws-run runWs bound to the Save/Delete busy flag and the error line. */
|
||||
private _runWs(payload: Record<string, unknown>): Promise<unknown> {
|
||||
return runWs(this, payload, {
|
||||
busy: (b) => { this._saving = b; },
|
||||
onError: (m) => { this._error = m; },
|
||||
});
|
||||
}
|
||||
|
||||
private async _save(): Promise<void> {
|
||||
if (!this._draft || !this._originalSnapshot) return;
|
||||
this._saving = true;
|
||||
// Save waits for a photo still uploading — it would miss the patch and
|
||||
// stay behind as an orphan (bug audit 2026-09-26 #2).
|
||||
if (this._saving || this._photos.uploading) return;
|
||||
this._error = "";
|
||||
this._photos.clearError();
|
||||
try {
|
||||
const patch: Record<string, unknown> = {
|
||||
type: "maintenance_supporter/task/history/update",
|
||||
entry_id: this._draft.entry_id,
|
||||
task_id: this._draft.task_id,
|
||||
original_timestamp: this._originalSnapshot.original_timestamp,
|
||||
};
|
||||
// Only send fields that actually changed — keeps the patch minimal
|
||||
// and the WS schema happy (it treats missing as "no change").
|
||||
if (this._draft.timestamp !== this._originalSnapshot.timestamp) {
|
||||
patch.timestamp = this._draft.timestamp;
|
||||
}
|
||||
if (this._draft.notes !== this._originalSnapshot.notes) {
|
||||
patch.notes = this._draft.notes;
|
||||
}
|
||||
if (this._draft.cost !== this._originalSnapshot.cost) {
|
||||
patch.cost = this._draft.cost;
|
||||
}
|
||||
if (this._draft.duration !== this._originalSnapshot.duration) {
|
||||
patch.duration = this._draft.duration;
|
||||
}
|
||||
if (this._draft.completed_by !== this._originalSnapshot.completed_by) {
|
||||
patch.completed_by = this._draft.completed_by;
|
||||
}
|
||||
// #130: send the parts selection only when it actually changed — the
|
||||
// backend reconciles stock by the delta, so a no-op must stay silent.
|
||||
if (this._partOptions !== null && this._partSelectionKey() !== this._partQtyOriginal) {
|
||||
patch.used_parts = (this._partOptions || [])
|
||||
.filter((o) => (this._partQty[`${o.entry_id}:${o.part_id}`] || 0) > 0)
|
||||
.map((o) => ({
|
||||
part_id: o.part_id,
|
||||
quantity: this._partQty[`${o.entry_id}:${o.part_id}`],
|
||||
...(o.foreign ? { entry_id: o.entry_id } : {}),
|
||||
}));
|
||||
}
|
||||
// #161 phase 2: readings. The scalar rides the changed-field check
|
||||
// like notes; the slot map is sent whole when any value differs —
|
||||
// the backend replaces the snapshot, a missing id means "unread".
|
||||
if (this._draft.reading_value !== this._originalSnapshot.reading_value) {
|
||||
patch.reading_value = this._draft.reading_value ?? null;
|
||||
}
|
||||
if (this._readingRows.length > 0 && JSON.stringify(this._readingNumbers()) !== this._readingsOriginal) {
|
||||
const numbers = this._readingNumbers();
|
||||
const map: Record<string, number | null> = {};
|
||||
for (const row of this._readingRows) map[row.id] = numbers[row.id] ?? null;
|
||||
patch.reading_values = map;
|
||||
}
|
||||
// #161: the photo list, only when it differs from what we opened with.
|
||||
if (JSON.stringify(this._photos.ids) !== this._photosOriginal) {
|
||||
patch.photo_doc_ids = this._photos.ids;
|
||||
}
|
||||
// Nothing changed → close without WS call
|
||||
const changedKeys = Object.keys(patch).filter(
|
||||
(k) => !["type", "entry_id", "task_id", "original_timestamp"].includes(k),
|
||||
);
|
||||
if (changedKeys.length === 0) {
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
await this.hass.connection.sendMessagePromise(patch);
|
||||
this._photos.markAttached(); // saved — they belong to the entry now
|
||||
// Notify upstream so they can refresh
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("history-entry-saved", {
|
||||
detail: {
|
||||
entry_id: this._draft.entry_id,
|
||||
task_id: this._draft.task_id,
|
||||
new_timestamp: this._draft.timestamp,
|
||||
},
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
this.close();
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._saving = false;
|
||||
const draft = this._draft;
|
||||
const original = this._originalSnapshot;
|
||||
const patch: Record<string, unknown> = {
|
||||
type: "maintenance_supporter/task/history/update",
|
||||
entry_id: draft.entry_id,
|
||||
task_id: draft.task_id,
|
||||
original_timestamp: original.original_timestamp,
|
||||
};
|
||||
// Only send fields that actually changed — keeps the patch minimal
|
||||
// and the WS schema happy (it treats missing as "no change").
|
||||
if (draft.timestamp !== original.timestamp) {
|
||||
patch.timestamp = draft.timestamp;
|
||||
}
|
||||
if (draft.notes !== original.notes) {
|
||||
patch.notes = draft.notes;
|
||||
}
|
||||
if (draft.cost !== original.cost) {
|
||||
patch.cost = draft.cost;
|
||||
}
|
||||
if (draft.duration !== original.duration) {
|
||||
patch.duration = draft.duration;
|
||||
}
|
||||
if (draft.completed_by !== original.completed_by) {
|
||||
patch.completed_by = draft.completed_by;
|
||||
}
|
||||
// #130: send the parts selection only when it actually changed — the
|
||||
// backend reconciles stock by the delta, so a no-op must stay silent.
|
||||
if (this._partOptions !== null && this._partSelectionKey() !== this._partQtyOriginal) {
|
||||
patch.used_parts = (this._partOptions || [])
|
||||
.filter((o) => (this._partQty[`${o.entry_id}:${o.part_id}`] || 0) > 0)
|
||||
.map((o) => ({
|
||||
part_id: o.part_id,
|
||||
quantity: this._partQty[`${o.entry_id}:${o.part_id}`],
|
||||
...(o.foreign ? { entry_id: o.entry_id } : {}),
|
||||
}));
|
||||
}
|
||||
// #161 phase 2: readings. The scalar rides the changed-field check
|
||||
// like notes; the slot map is sent whole when any value differs —
|
||||
// the backend replaces the snapshot, a missing id means "unread".
|
||||
if (draft.reading_value !== original.reading_value) {
|
||||
patch.reading_value = draft.reading_value ?? null;
|
||||
}
|
||||
if (this._readingRows.length > 0 && JSON.stringify(this._readingNumbers()) !== this._readingsOriginal) {
|
||||
const numbers = this._readingNumbers();
|
||||
const map: Record<string, number | null> = {};
|
||||
for (const row of this._readingRows) map[row.id] = numbers[row.id] ?? null;
|
||||
patch.reading_values = map;
|
||||
}
|
||||
// #161: the photo list, only when it differs from what we opened with.
|
||||
if (JSON.stringify(this._photos.ids) !== this._photosOriginal) {
|
||||
patch.photo_doc_ids = this._photos.ids;
|
||||
}
|
||||
// Nothing changed → close without WS call
|
||||
const changedKeys = Object.keys(patch).filter(
|
||||
(k) => !["type", "entry_id", "task_id", "original_timestamp"].includes(k),
|
||||
);
|
||||
if (changedKeys.length === 0) {
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
if ((await this._runWs(patch)) === undefined) return;
|
||||
this._photos.markAttached(); // saved — they belong to the entry now
|
||||
// Notify upstream so they can refresh
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("history-entry-saved", {
|
||||
detail: {
|
||||
entry_id: draft.entry_id,
|
||||
task_id: draft.task_id,
|
||||
new_timestamp: draft.timestamp,
|
||||
},
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
this.close();
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -343,9 +373,7 @@ export class MaintenanceHistoryEditDialog extends LitElement {
|
||||
const L = this._lang;
|
||||
const d = this._draft;
|
||||
const error = this._error || this._photos.errorText(L);
|
||||
return html`
|
||||
<div class="backdrop" @click=${this.close}></div>
|
||||
<div class="dialog" role="dialog" aria-modal="true">
|
||||
return renderModalShell(() => this.close(), html`
|
||||
<h2>${t("history_edit_title", L)}</h2>
|
||||
<div class="entry-type">
|
||||
<ha-icon icon="mdi:tag-outline"></ha-icon>
|
||||
@@ -387,11 +415,11 @@ export class MaintenanceHistoryEditDialog extends LitElement {
|
||||
</label>
|
||||
<label>
|
||||
<span>${t("duration", L)}</span>
|
||||
<input type="number" min="0"
|
||||
<input type="number" min="0" step="1" inputmode="numeric"
|
||||
.value=${d.duration != null ? String(d.duration) : ""}
|
||||
@input=${(e: Event) => {
|
||||
const v = (e.target as HTMLInputElement).value;
|
||||
this._set("duration", v ? Number(v) : null);
|
||||
// Whole minutes, like the server stores them (helpers/duration).
|
||||
this._set("duration", parseDurationMinutes((e.target as HTMLInputElement).value));
|
||||
}} />
|
||||
</label>
|
||||
</div>
|
||||
@@ -411,7 +439,7 @@ export class MaintenanceHistoryEditDialog extends LitElement {
|
||||
}} />
|
||||
<span class="part-label">${o.name}${o.foreign && o.object_name ? ` (${o.object_name})` : ""}</span>
|
||||
${qty > 0 ? html`
|
||||
<input class="part-qty" type="number" min="0.01" max="999" step="0.01"
|
||||
<input class="part-qty" type="number" min=${PART_QTY_RANGE[0]} max=${PART_QTY_RANGE[1]} step="0.01"
|
||||
.value=${String(qty)}
|
||||
@input=${(e: Event) => {
|
||||
const v = parseFloat((e.target as HTMLInputElement).value);
|
||||
@@ -449,12 +477,16 @@ export class MaintenanceHistoryEditDialog extends LitElement {
|
||||
<button class="cancel" @click=${this.close} ?disabled=${this._saving}>
|
||||
${t("cancel", L)}
|
||||
</button>
|
||||
<button class="save" @click=${this._save} ?disabled=${this._saving}>
|
||||
<button class="save" @click=${this._save} ?disabled=${this._saving || this._photos.uploading}
|
||||
title=${this._photos.uploading ? t("uploading", L) : ""}>
|
||||
${this._saving ? t("saving", L) : t("save", L)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
`);
|
||||
}
|
||||
|
||||
protected updated(changed: Map<string, unknown>): void {
|
||||
if (changed.has("_open") && this._open) focusModalShell(this.shadowRoot);
|
||||
}
|
||||
|
||||
/** #161 phase 2: per-slot rows for a slot task (entry snapshot ∪ current
|
||||
@@ -490,26 +522,8 @@ export class MaintenanceHistoryEditDialog extends LitElement {
|
||||
</label>`;
|
||||
}
|
||||
|
||||
static styles = [photoPickerStyles, css`
|
||||
:host { display: contents; }
|
||||
.backdrop {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
z-index: 100;
|
||||
}
|
||||
.dialog {
|
||||
position: fixed; left: 50%; top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 95vw; max-width: 480px;
|
||||
background: var(--card-background-color, var(--ha-card-background, #1c1c1c));
|
||||
color: var(--primary-text-color);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.4);
|
||||
padding: 20px;
|
||||
display: flex; flex-direction: column; gap: 12px;
|
||||
z-index: 101;
|
||||
max-height: 90vh; overflow: auto;
|
||||
}
|
||||
static styles = [photoPickerStyles, modalShellStyles, css`
|
||||
:host { display: contents; --ms-modal-max-height: 90vh; --ms-modal-gap: 12px; }
|
||||
h2 { margin: 0; font-size: 18px; }
|
||||
.entry-type {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { property, state } from "lit/decorators.js";
|
||||
import type { HomeAssistant, MaintenanceObject, MaintenanceObjectResponse } from "../types";
|
||||
import { t, langOf } from "../styles";
|
||||
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { runWs } from "../helpers/ws-run";
|
||||
import "./ms-textfield";
|
||||
import "./ms-date-field";
|
||||
|
||||
@@ -74,48 +74,31 @@ export class MaintenanceObjectDialog extends LitElement {
|
||||
private async _save(): Promise<void> {
|
||||
if (this._loading) return; // synchronous re-entry guard (double-click)
|
||||
if (!this._name.trim()) return;
|
||||
this._loading = true;
|
||||
this._error = "";
|
||||
try {
|
||||
if (this._entryId) {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/object/update",
|
||||
entry_id: this._entryId,
|
||||
name: this._name,
|
||||
manufacturer: this._manufacturer || null,
|
||||
model: this._model || null,
|
||||
serial_number: this._serialNumber || null,
|
||||
area_id: this._areaId || null,
|
||||
installation_date: this._installationDate || null,
|
||||
warranty_expiry: this._warrantyExpiry || null,
|
||||
documentation_url: this._documentationUrl.trim() || null,
|
||||
notes: this._notes.trim() || null,
|
||||
ha_device_id: this._haDeviceId || null,
|
||||
parent_entry_id: this._parentEntryId || null,
|
||||
});
|
||||
} else {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/object/create",
|
||||
name: this._name,
|
||||
manufacturer: this._manufacturer || null,
|
||||
model: this._model || null,
|
||||
serial_number: this._serialNumber || null,
|
||||
area_id: this._areaId || null,
|
||||
installation_date: this._installationDate || null,
|
||||
warranty_expiry: this._warrantyExpiry || null,
|
||||
documentation_url: this._documentationUrl.trim() || null,
|
||||
notes: this._notes.trim() || null,
|
||||
ha_device_id: this._haDeviceId || null,
|
||||
parent_entry_id: this._parentEntryId || null,
|
||||
});
|
||||
}
|
||||
this._open = false;
|
||||
this.dispatchEvent(new CustomEvent("object-saved"));
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang, t("save_error", this._lang));
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
// One field set for create and update (the two payloads were copies).
|
||||
const fields = {
|
||||
name: this._name,
|
||||
manufacturer: this._manufacturer || null,
|
||||
model: this._model || null,
|
||||
serial_number: this._serialNumber || null,
|
||||
area_id: this._areaId || null,
|
||||
installation_date: this._installationDate || null,
|
||||
warranty_expiry: this._warrantyExpiry || null,
|
||||
documentation_url: this._documentationUrl.trim() || null,
|
||||
notes: this._notes.trim() || null,
|
||||
ha_device_id: this._haDeviceId || null,
|
||||
parent_entry_id: this._parentEntryId || null,
|
||||
};
|
||||
const res = await runWs(
|
||||
this,
|
||||
this._entryId
|
||||
? { type: "maintenance_supporter/object/update", entry_id: this._entryId, ...fields }
|
||||
: { type: "maintenance_supporter/object/create", ...fields },
|
||||
{ busy: (b) => { this._loading = b; }, fallbackKey: "save_error", onError: (m) => { this._error = m; } },
|
||||
);
|
||||
if (res === undefined) return;
|
||||
this._open = false;
|
||||
this.dispatchEvent(new CustomEvent("object-saved"));
|
||||
}
|
||||
|
||||
private _parentChoices(): MaintenanceObjectResponse[] {
|
||||
|
||||
+77
-82
@@ -12,8 +12,13 @@ import { LitElement, html, css, nothing } from "lit";
|
||||
import { isSafeHttpUrl } from "../helpers/url";
|
||||
import { renderNotesMarkdown } from "../helpers/notes-markdown";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { t, STATUS_COLORS, langOf } from "../styles";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { t, langOf } from "../styles";
|
||||
import { runWs } from "../helpers/ws-run";
|
||||
import { statusColor, statusKey, statusLabel } from "../renderers/status";
|
||||
import { fetchSettingsOnce } from "../helpers/settings-cache";
|
||||
import { canWrite, NO_DELEGATION, type WriteAccess } from "../helpers/permissions";
|
||||
import { confirmAction } from "../helpers/confirm";
|
||||
import { focusModalShell, modalShellStyles, renderModalShell } from "../helpers/modal-shell";
|
||||
import type { HomeAssistant, MaintenanceObject, MaintenanceTask } from "../types";
|
||||
|
||||
interface ObjectFull {
|
||||
@@ -30,6 +35,9 @@ export class MaintenanceObjectQuickActionsDialog extends LitElement {
|
||||
@state() private _data: ObjectFull | null = null;
|
||||
@state() private _busy = false;
|
||||
@state() private _error = "";
|
||||
/** Operator-write delegation — Add task / Edit / Archive / Delete follow
|
||||
* canWrite() like the panel (DRY audit 2026-09-26). */
|
||||
@state() private _access: WriteAccess = NO_DELEGATION;
|
||||
|
||||
private get _lang(): string {
|
||||
return langOf(this.hass);
|
||||
@@ -39,7 +47,10 @@ export class MaintenanceObjectQuickActionsDialog extends LitElement {
|
||||
this._entryId = entryId;
|
||||
this._error = "";
|
||||
this._open = true;
|
||||
await this._load();
|
||||
await Promise.all([
|
||||
this._load(),
|
||||
fetchSettingsOnce(this.hass).then((s) => { this._access = s.access; }),
|
||||
]);
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
@@ -50,15 +61,21 @@ export class MaintenanceObjectQuickActionsDialog extends LitElement {
|
||||
|
||||
private async _load(): Promise<void> {
|
||||
if (!this._entryId) return;
|
||||
try {
|
||||
const r = await this.hass.connection.sendMessagePromise<ObjectFull>({
|
||||
type: "maintenance_supporter/object",
|
||||
entry_id: this._entryId,
|
||||
});
|
||||
this._data = r;
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
}
|
||||
const r = await runWs<ObjectFull>(
|
||||
this,
|
||||
{ type: "maintenance_supporter/object", entry_id: this._entryId },
|
||||
{ onError: (m) => { this._error = m; } },
|
||||
);
|
||||
if (r !== undefined) this._data = r;
|
||||
}
|
||||
|
||||
/** helpers/ws-run runWs bound to this dialog's busy flag and error line. */
|
||||
private _runWs(payload: Record<string, unknown>): Promise<unknown> {
|
||||
this._error = "";
|
||||
return runWs(this, payload, {
|
||||
busy: (b) => { this._busy = b; },
|
||||
onError: (m) => { this._error = m; },
|
||||
});
|
||||
}
|
||||
|
||||
private _onEditObject(): void {
|
||||
@@ -79,58 +96,50 @@ export class MaintenanceObjectQuickActionsDialog extends LitElement {
|
||||
|
||||
private async _onDelete(): Promise<void> {
|
||||
if (!this._entryId || !this._data) return;
|
||||
const confirmText = t("delete_object_confirm", this._lang)
|
||||
|| `Delete "${this._data.object.name}" and all its tasks?`;
|
||||
if (!window.confirm(confirmText)) return;
|
||||
this._busy = true;
|
||||
this._error = "";
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/object/delete",
|
||||
entry_id: this._entryId,
|
||||
});
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("object-deleted", {
|
||||
detail: { entry_id: this._entryId },
|
||||
bubbles: true, composed: true,
|
||||
}),
|
||||
);
|
||||
this.close();
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._busy = false;
|
||||
}
|
||||
const ok = await confirmAction(this.hass, {
|
||||
title: t("delete", this._lang),
|
||||
message: t("delete_object_confirm", this._lang),
|
||||
confirmText: t("delete", this._lang),
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
const entryId = this._entryId;
|
||||
if ((await this._runWs({ type: "maintenance_supporter/object/delete", entry_id: entryId })) === undefined) return;
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("object-deleted", {
|
||||
detail: { entry_id: entryId },
|
||||
bubbles: true, composed: true,
|
||||
}),
|
||||
);
|
||||
this.close();
|
||||
}
|
||||
|
||||
private async _onArchiveObject(): Promise<void> {
|
||||
if (!this._entryId || !this._data) return;
|
||||
const archived = !!this._data.object.archived;
|
||||
if (!archived) {
|
||||
const confirmText = t("confirm_archive_object", this._lang);
|
||||
if (!window.confirm(confirmText)) return;
|
||||
}
|
||||
this._busy = true;
|
||||
this._error = "";
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: archived
|
||||
? "maintenance_supporter/object/unarchive"
|
||||
: "maintenance_supporter/object/archive",
|
||||
entry_id: this._entryId,
|
||||
const ok = await confirmAction(this.hass, {
|
||||
title: t("archive_object", this._lang),
|
||||
message: t("confirm_archive_object", this._lang),
|
||||
confirmText: t("archive_object", this._lang),
|
||||
});
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("object-changed", {
|
||||
detail: { entry_id: this._entryId },
|
||||
bubbles: true, composed: true,
|
||||
}),
|
||||
);
|
||||
this.close();
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._busy = false;
|
||||
if (!ok) return;
|
||||
}
|
||||
const entryId = this._entryId;
|
||||
const res = await this._runWs({
|
||||
type: archived
|
||||
? "maintenance_supporter/object/unarchive"
|
||||
: "maintenance_supporter/object/archive",
|
||||
entry_id: entryId,
|
||||
});
|
||||
if (res === undefined) return;
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("object-changed", {
|
||||
detail: { entry_id: entryId },
|
||||
bubbles: true, composed: true,
|
||||
}),
|
||||
);
|
||||
this.close();
|
||||
}
|
||||
|
||||
private _onTaskClick(taskId: string): void {
|
||||
@@ -148,11 +157,9 @@ export class MaintenanceObjectQuickActionsDialog extends LitElement {
|
||||
const data = this._data;
|
||||
const obj = data?.object;
|
||||
const tasks = data?.tasks || [];
|
||||
const isAdmin = (this.hass?.user?.is_admin ?? true) as boolean;
|
||||
const writer = canWrite(this.hass?.user, this._access);
|
||||
|
||||
return html`
|
||||
<div class="backdrop" @click=${this.close}></div>
|
||||
<div class="dialog" role="dialog" aria-modal="true">
|
||||
return renderModalShell(() => this.close(), html`
|
||||
${data && obj
|
||||
? html`
|
||||
<div class="header">
|
||||
@@ -173,9 +180,9 @@ export class MaintenanceObjectQuickActionsDialog extends LitElement {
|
||||
<div class="task-list">
|
||||
${tasks.map((task) => html`
|
||||
<div class="task-row" @click=${() => this._onTaskClick(task.id)}>
|
||||
<span class="status-dot" style="background: ${STATUS_COLORS[task.status] || "#ccc"}"></span>
|
||||
<span class="status-dot" style="background: ${statusColor(task)}"></span>
|
||||
<span class="task-name">${task.name}</span>
|
||||
<span class="task-status">${t(task.status || "ok", L)}</span>
|
||||
<span class="task-status ${statusKey(task)}">${statusLabel(statusKey(task), L)}</span>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
@@ -191,7 +198,7 @@ export class MaintenanceObjectQuickActionsDialog extends LitElement {
|
||||
`
|
||||
: nothing}
|
||||
|
||||
${isAdmin
|
||||
${writer
|
||||
? html`
|
||||
<div class="actions">
|
||||
<button class="btn primary" @click=${this._onAddTask} ?disabled=${this._busy}>
|
||||
@@ -215,8 +222,11 @@ export class MaintenanceObjectQuickActionsDialog extends LitElement {
|
||||
: nothing}
|
||||
`
|
||||
: html`<div class="loading">${t("loading", L)}</div>`}
|
||||
</div>
|
||||
`;
|
||||
`);
|
||||
}
|
||||
|
||||
protected updated(changed: Map<string, unknown>): void {
|
||||
if (changed.has("_open") && this._open) focusModalShell(this.shadowRoot);
|
||||
}
|
||||
|
||||
private _renderMetaRow(obj: MaintenanceObject) {
|
||||
@@ -251,23 +261,8 @@ export class MaintenanceObjectQuickActionsDialog extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
static styles = [modalShellStyles, css`
|
||||
:host { display: contents; }
|
||||
.backdrop {
|
||||
position: fixed; inset: 0; z-index: 100; background: rgba(0,0,0,0.5);
|
||||
}
|
||||
.dialog {
|
||||
position: fixed; left: 50%; top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 95vw; max-width: 480px;
|
||||
max-height: 92vh; overflow: auto;
|
||||
background: var(--card-background-color, var(--ha-card-background, #1c1c1c));
|
||||
color: var(--primary-text-color);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.4);
|
||||
padding: 20px; z-index: 101;
|
||||
display: flex; flex-direction: column; gap: 14px;
|
||||
}
|
||||
.header { display: flex; flex-direction: column; gap: 6px; }
|
||||
.title { font-size: 20px; font-weight: 600; }
|
||||
.meta { display: flex; flex-direction: column; gap: 4px; padding-top: 4px; border-top: 1px solid var(--divider-color); }
|
||||
@@ -311,7 +306,7 @@ export class MaintenanceObjectQuickActionsDialog extends LitElement {
|
||||
.btn ha-icon { --mdc-icon-size: 16px; }
|
||||
.loading { padding: 24px; text-align: center; color: var(--secondary-text-color); }
|
||||
.error { padding: 8px; border-radius: 6px; background: rgba(211,47,47,0.1); color: var(--error-color); font-size: 13px; }
|
||||
`;
|
||||
`];
|
||||
}
|
||||
|
||||
if (!customElements.get("maintenance-object-quick-actions-dialog")) {
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
import { LitElement, html, css, nothing } from "lit";
|
||||
import { isSafeHttpUrl } from "../helpers/url";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { t, ensureLocale, langOf, formatCost } from "../styles";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { t, ensureLocale, langOf, formatCost, formatNumber, formatQty } from "../styles";
|
||||
import { runWs } from "../helpers/ws-run";
|
||||
import type { HomeAssistant, MaintenancePart } from "../types";
|
||||
// Per-part document links (v2.26) — the task-documents component in part mode.
|
||||
import "./task-documents";
|
||||
@@ -83,17 +83,14 @@ export class MaintenancePartsSection extends LitElement {
|
||||
this.dispatchEvent(new CustomEvent("parts-changed", { bubbles: true, composed: true }));
|
||||
}
|
||||
|
||||
private async _send<T>(msg: Record<string, unknown>): Promise<T | null> {
|
||||
this._busy = true;
|
||||
/** helpers/ws-run runWs bound to the busy flag and the error line;
|
||||
* undefined = refused (the reason is on the error line). */
|
||||
private _send<T>(msg: Record<string, unknown>): Promise<T | null | undefined> {
|
||||
this._error = "";
|
||||
try {
|
||||
return await this.hass.connection.sendMessagePromise<T>(msg);
|
||||
} catch (err) {
|
||||
this._error = describeWsError(err, this._lang);
|
||||
return null;
|
||||
} finally {
|
||||
this._busy = false;
|
||||
}
|
||||
return runWs<T>(this, msg, {
|
||||
busy: (b) => { this._busy = b; },
|
||||
onError: (m) => { this._error = m; },
|
||||
});
|
||||
}
|
||||
|
||||
private _openAdd(): void {
|
||||
@@ -141,11 +138,11 @@ export class MaintenancePartsSection extends LitElement {
|
||||
|
||||
private async _save(): Promise<void> {
|
||||
const f = this._editing;
|
||||
if (!f || !f.name.trim()) return;
|
||||
if (this._busy || !f || !f.name.trim()) return;
|
||||
const payload = this._formValue(f);
|
||||
const type = f.id ? "maintenance_supporter/part/update" : "maintenance_supporter/part/create";
|
||||
const result = await this._send<{ part_id?: string }>(f.id ? { type, part_id: f.id, ...payload } : { type, ...payload });
|
||||
if (result !== null) {
|
||||
if (result !== undefined) {
|
||||
this._editing = null;
|
||||
this._notifyChanged();
|
||||
}
|
||||
@@ -160,10 +157,14 @@ export class MaintenancePartsSection extends LitElement {
|
||||
entry_id: this.entryId,
|
||||
part_id: part.id,
|
||||
});
|
||||
if (result !== null) this._notifyChanged();
|
||||
if (result !== undefined) this._notifyChanged();
|
||||
}
|
||||
|
||||
private async _restock(part: MaintenancePart): Promise<void> {
|
||||
// Enter + a click on ✓ (or a double Enter) fired two restocks — the
|
||||
// stock went up twice (bug audit 2026-09-26). _send sets _busy before
|
||||
// its first await, so the second call lands here and stops.
|
||||
if (this._busy) return;
|
||||
const qty = parseFloat(this._restockQty);
|
||||
if (!Number.isFinite(qty) || qty === 0) {
|
||||
// Don't silently swallow a no-op amount — keep the input open and mark
|
||||
@@ -179,9 +180,9 @@ export class MaintenancePartsSection extends LitElement {
|
||||
delta: qty,
|
||||
});
|
||||
this._restockFor = null;
|
||||
if (result !== null) {
|
||||
if (result !== undefined) {
|
||||
// Instant local feedback; the panel refresh follows via parts-changed.
|
||||
part.stock = result.stock;
|
||||
part.stock = result?.stock;
|
||||
this.requestUpdate();
|
||||
this._notifyChanged();
|
||||
}
|
||||
@@ -208,8 +209,8 @@ export class MaintenancePartsSection extends LitElement {
|
||||
: part.name}
|
||||
${tracked
|
||||
? html`<span class="stock-badge ${part.is_low ? "low" : ""}"
|
||||
>${part.stock}${part.unit ? ` ${part.unit}` : ""}${part.reorder_threshold != null
|
||||
? html`<span class="threshold">/${part.reorder_threshold}</span>`
|
||||
>${formatQty(part.stock!, part.unit, L)}${part.reorder_threshold != null
|
||||
? html`<span class="threshold">/${formatNumber(part.reorder_threshold, L)}</span>`
|
||||
: nothing}</span
|
||||
>`
|
||||
: nothing}
|
||||
@@ -242,7 +243,7 @@ export class MaintenancePartsSection extends LitElement {
|
||||
if (e.key === "Escape") this._restockFor = null;
|
||||
}}
|
||||
/>
|
||||
<ha-icon-button title=${t("save", L)} @click=${() => this._restock(part)}
|
||||
<ha-icon-button title=${t("save", L)} .disabled=${this._busy} @click=${() => this._restock(part)}
|
||||
><ha-icon icon="mdi:check"></ha-icon
|
||||
></ha-icon-button>
|
||||
`
|
||||
|
||||
+17
-29
@@ -13,7 +13,7 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
|
||||
import { t, ensureLocale, langOf } from "../styles";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { runWs } from "../helpers/ws-run";
|
||||
import type { HomeAssistant, SavedView, SavedViewFilters } from "../types";
|
||||
|
||||
interface ViewsResponse {
|
||||
@@ -65,40 +65,28 @@ export class MaintenanceSavedViewsDialog extends LitElement {
|
||||
private _save = async (): Promise<void> => {
|
||||
const name = this._name.trim();
|
||||
if (!name || this._busy || !this._filters) return;
|
||||
this._busy = true;
|
||||
this._error = "";
|
||||
try {
|
||||
const res = await this.hass.connection.sendMessagePromise<ViewsResponse>({
|
||||
type: "maintenance_supporter/views/save",
|
||||
name,
|
||||
filters: this._filters,
|
||||
});
|
||||
this._name = "";
|
||||
this._emitChanged(res.views || []);
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._busy = false;
|
||||
}
|
||||
const res = await this._runWs({ type: "maintenance_supporter/views/save", name, filters: this._filters });
|
||||
if (res === undefined) return;
|
||||
this._name = "";
|
||||
this._emitChanged(res?.views || []);
|
||||
};
|
||||
|
||||
private _delete = async (viewId: string): Promise<void> => {
|
||||
if (this._busy) return;
|
||||
this._busy = true;
|
||||
this._error = "";
|
||||
try {
|
||||
const res = await this.hass.connection.sendMessagePromise<ViewsResponse>({
|
||||
type: "maintenance_supporter/views/delete",
|
||||
view_id: viewId,
|
||||
});
|
||||
this._emitChanged(res.views || []);
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._busy = false;
|
||||
}
|
||||
const res = await this._runWs({ type: "maintenance_supporter/views/delete", view_id: viewId });
|
||||
if (res === undefined) return;
|
||||
this._emitChanged(res?.views || []);
|
||||
};
|
||||
|
||||
/** helpers/ws-run runWs bound to the busy flag and the error line. */
|
||||
private _runWs(payload: Record<string, unknown>): Promise<ViewsResponse | null | undefined> {
|
||||
this._error = "";
|
||||
return runWs<ViewsResponse>(this, payload, {
|
||||
busy: (b) => { this._busy = b; },
|
||||
onError: (m) => { this._error = m; },
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this._open) return html``;
|
||||
const L = this._lang;
|
||||
|
||||
+27
-36
@@ -4,7 +4,8 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
|
||||
import { t, langOf } from "../styles";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { runWs } from "../helpers/ws-run";
|
||||
import { SEASONAL_FACTOR_RANGE } from "../helpers/setting-ranges";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
const MONTH_KEYS = [
|
||||
@@ -56,7 +57,7 @@ export class SeasonalOverridesDialog extends LitElement {
|
||||
this._error = `${t("month_" + ["jan","feb","mar","apr","may","jun","jul","aug","sep","oct","nov","dec"][i], this._lang)}: ${t("seasonal_override_invalid", this._lang)}`;
|
||||
return null;
|
||||
}
|
||||
if (num < 0.1 || num > 5.0) {
|
||||
if (num < SEASONAL_FACTOR_RANGE[0] || num > SEASONAL_FACTOR_RANGE[1]) {
|
||||
this._error = t("seasonal_override_range", this._lang);
|
||||
return null;
|
||||
}
|
||||
@@ -68,42 +69,32 @@ export class SeasonalOverridesDialog extends LitElement {
|
||||
private _save = async (): Promise<void> => {
|
||||
const overrides = this._buildOverrides();
|
||||
if (overrides === null) return;
|
||||
this._loading = true;
|
||||
this._error = "";
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/task/seasonal_overrides",
|
||||
entry_id: this._entryId,
|
||||
task_id: this._taskId,
|
||||
overrides,
|
||||
});
|
||||
this._open = false;
|
||||
this.dispatchEvent(new CustomEvent("overrides-saved"));
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang, t("save_error", this._lang));
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
if ((await this._send(overrides)) === undefined) return;
|
||||
this._open = false;
|
||||
this.dispatchEvent(new CustomEvent("overrides-saved"));
|
||||
};
|
||||
|
||||
private _clearAll = async (): Promise<void> => {
|
||||
this._loading = true;
|
||||
/** helpers/ws-run runWs: the overrides write with the loading flag and
|
||||
* the error line ("Save failed" for a non-WS error). */
|
||||
private _send(overrides: Record<number, number>): Promise<unknown> {
|
||||
this._error = "";
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/task/seasonal_overrides",
|
||||
entry_id: this._entryId,
|
||||
task_id: this._taskId,
|
||||
overrides: {},
|
||||
});
|
||||
this._values = new Array(12).fill("");
|
||||
this._open = false;
|
||||
this.dispatchEvent(new CustomEvent("overrides-saved"));
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang, t("save_error", this._lang));
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
return runWs(this, {
|
||||
type: "maintenance_supporter/task/seasonal_overrides",
|
||||
entry_id: this._entryId,
|
||||
task_id: this._taskId,
|
||||
overrides,
|
||||
}, {
|
||||
busy: (b) => { this._loading = b; },
|
||||
fallbackKey: "save_error",
|
||||
onError: (m) => { this._error = m; },
|
||||
});
|
||||
}
|
||||
|
||||
private _clearAll = async (): Promise<void> => {
|
||||
if ((await this._send({})) === undefined) return;
|
||||
this._values = new Array(12).fill("");
|
||||
this._open = false;
|
||||
this.dispatchEvent(new CustomEvent("overrides-saved"));
|
||||
};
|
||||
|
||||
render() {
|
||||
@@ -118,7 +109,7 @@ export class SeasonalOverridesDialog extends LitElement {
|
||||
${MONTH_KEYS.map((key, i) => html`
|
||||
<label class="month">
|
||||
<span class="mn">${t(key, L)}</span>
|
||||
<input type="number" step="0.1" min="0.1" max="5.0"
|
||||
<input type="number" step="0.1" min=${SEASONAL_FACTOR_RANGE[0]} max=${SEASONAL_FACTOR_RANGE[1]}
|
||||
placeholder="1.0"
|
||||
.value=${this._values[i]}
|
||||
@input=${(e: Event) => {
|
||||
|
||||
@@ -5,17 +5,20 @@ import { property, state } from "lit/decorators.js";
|
||||
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
||||
import { live } from "lit/directives/live.js";
|
||||
import type { HomeAssistant, AdvancedFeatures, BudgetStatus, HAUser } from "../types";
|
||||
import { t, langOf, personStyles, syncCurrencyDecimals} from "../styles";
|
||||
import { t, langOf, personStyles, syncCurrencyDecimals, formatNumber } from "../styles";
|
||||
import { signApiPath } from "../helpers/document-url";
|
||||
import { downloadUrl } from "../helpers/download";
|
||||
import { UserService } from "../user-service";
|
||||
import { AVATAR_PALETTE, personOf, renderPersonAvatar } from "../helpers/person";
|
||||
import { OBJECT_COLUMNS, sanitizeColumns } from "../helpers/object-columns";
|
||||
import { downloadTextFile } from "../helpers/download";
|
||||
import { invalidateSettingsCache } from "../helpers/settings-cache";
|
||||
import { SETTING_INT_RANGES, settingIntRange } from "../helpers/setting-ranges";
|
||||
import { invalidateSettingsCache, ROW_ACTION_STYLES } from "../helpers/settings-cache";
|
||||
import { SETTING_INT_RANGES, VACATION_BUFFER_DAYS_RANGE, settingIntRange } from "../helpers/setting-ranges";
|
||||
import { ToastTimer } from "../helpers/toast";
|
||||
import { isoDateLocal } from "../helpers/calendar-bucket";
|
||||
import { runWs } from "../helpers/ws-run";
|
||||
import { DOCS_ARCHIVE_MAX_BYTES, postMultipart } from "../helpers/photo-upload";
|
||||
import { countryName, detectionReasons, dwellingLabel, type HomeProfile } from "../helpers/home-profile";
|
||||
import "./ms-date-field";
|
||||
|
||||
/** One household member and the notify services they actually resolve to.
|
||||
@@ -37,6 +40,9 @@ interface SettingsResponse {
|
||||
objects_table_columns?: string[];
|
||||
/** v2.21: template-gallery curation — hidden template ids. */
|
||||
disabled_template_ids?: string[];
|
||||
/** v2.93: home profile dwelling type — auto | house | apartment. */
|
||||
home_type?: string;
|
||||
home_region?: string;
|
||||
general: {
|
||||
default_warning_days: number;
|
||||
notifications_enabled: boolean;
|
||||
@@ -501,9 +507,18 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
}
|
||||
};
|
||||
|
||||
private readonly _toastTimer = new ToastTimer();
|
||||
|
||||
private _showToast(msg: string): void {
|
||||
this._toast = msg;
|
||||
setTimeout(() => { this._toast = ""; }, 3000);
|
||||
this._toastTimer.schedule(() => { this._toast = ""; });
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
// A pending hide must not fire into a detached view (DRY audit 2026-09-26).
|
||||
this._toastTimer.clear();
|
||||
this._toast = "";
|
||||
}
|
||||
|
||||
/** One WS round-trip; a rejection becomes a toast carrying the server's
|
||||
@@ -595,6 +610,7 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
${this._renderVacation(L)}
|
||||
${this._renderPrintQr(L)}
|
||||
${this._renderImportExport(L)}
|
||||
${this._renderHomeProfile(L)}
|
||||
${this._renderTemplateToggles(L)}
|
||||
${this._toast ? html`<div class="settings-toast">${this._toast}</div>` : nothing}
|
||||
`;
|
||||
@@ -705,6 +721,8 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
|
||||
@state() private _allTemplates: Array<{ id: string; name: string; category: string; disabled?: boolean }> = [];
|
||||
@state() private _templateCategories: Record<string, Record<string, string>> = {};
|
||||
/** v2.93: served with the templates read (see helpers/home-profile.ts). */
|
||||
@state() private _homeProfile: HomeProfile | null = null;
|
||||
@state() private _tplOpenGroups: Set<string> = new Set();
|
||||
|
||||
// One-shot request guard: keyed on a plain flag, NOT on the result being
|
||||
@@ -719,15 +737,102 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
const res = await this.hass.connection.sendMessagePromise<{
|
||||
templates: Array<{ id: string; name: string; category: string; disabled?: boolean }>;
|
||||
categories: Record<string, Record<string, string>>;
|
||||
profile?: HomeProfile;
|
||||
}>({
|
||||
type: "maintenance_supporter/templates",
|
||||
language: this._lang,
|
||||
});
|
||||
this._allTemplates = res.templates || [];
|
||||
this._templateCategories = res.categories || {};
|
||||
this._homeProfile = res.profile ?? null;
|
||||
} catch { /* section renders empty; retried on next open */ }
|
||||
}
|
||||
|
||||
// --- Section: Home profile (v2.93) ---
|
||||
|
||||
private _renderHomeProfile(L: string) {
|
||||
this._loadTemplates();
|
||||
const p = this._homeProfile;
|
||||
const setting = this._settings!.home_type || "auto";
|
||||
const c = p?.climate;
|
||||
const climate = c && c.koppen
|
||||
? t("home_climate_value", L)
|
||||
.replace("{koppen}", c.koppen)
|
||||
.replace("{cold}", String(c.coldest_c ?? "–"))
|
||||
.replace("{warm}", String(c.warmest_c ?? "–"))
|
||||
: t("home_climate_unknown", L);
|
||||
const country = countryName(p?.country, L);
|
||||
return html`
|
||||
<div class="settings-section" data-section="home_profile">
|
||||
<h3>${t("home_profile_title", L)}</h3>
|
||||
<p class="section-desc">${t("home_profile_hint", L)}</p>
|
||||
<label class="setting-row">
|
||||
<span class="setting-label">${t("home_type_label", L)}</span>
|
||||
<select class="home-type" .value=${live(setting)}
|
||||
@change=${(e: Event) => void this._setHomeType((e.target as HTMLSelectElement).value)}>
|
||||
<option value="auto" ?selected=${setting === "auto"}>
|
||||
${t("home_type_auto", L).replace("{detected}", dwellingLabel(p?.dwelling_detected ?? "unknown", L))}
|
||||
</option>
|
||||
<option value="house" ?selected=${setting === "house"}>${t("home_dwelling_house", L)}</option>
|
||||
<option value="apartment" ?selected=${setting === "apartment"}>${t("home_dwelling_apartment", L)}</option>
|
||||
</select>
|
||||
</label>
|
||||
${p && p.dwelling_reasons.length
|
||||
? html`<div class="setting-hint home-detected">
|
||||
${t("home_detected_from", L).replace("{reasons}", detectionReasons(p.dwelling_reasons, L))}
|
||||
</div>`
|
||||
: nothing}
|
||||
<div class="setting-row home-climate">
|
||||
<span class="setting-label">${t("home_climate_label", L)}</span>
|
||||
<span class="home-climate-value">${country ? `${country} · ` : ""}${climate}</span>
|
||||
</div>
|
||||
${p?.regions?.length ? this._renderHomeRegion(p, L) : nothing}
|
||||
${p?.hemisphere === "south" ? html`<div class="setting-hint">${t("home_hemisphere_south", L)}</div>` : nothing}
|
||||
${p && p.traits.length
|
||||
? html`<div class="home-traits">
|
||||
${p.traits.map((tr) => html`<span class="home-trait">${t(`home_trait_${tr}`, L)}</span>`)}
|
||||
</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/** 2.94: the state / province whose rules the templates follow — shown
|
||||
* only where the country has such rules (data/regions). */
|
||||
private _renderHomeRegion(p: HomeProfile, L: string) {
|
||||
const regions = p.regions || [];
|
||||
const setting = this._settings!.home_region || "auto";
|
||||
const value = regions.some((r) => r.code === setting) ? setting : "auto";
|
||||
const detected = p.region_detected_name || t("home_region_none", L);
|
||||
return html`
|
||||
<label class="setting-row">
|
||||
<span class="setting-label">${t("home_region_label", L)}</span>
|
||||
<select class="home-region" .value=${live(value)}
|
||||
@change=${(e: Event) => void this._setHomeRegion((e.target as HTMLSelectElement).value)}>
|
||||
<option value="auto" ?selected=${value === "auto"}>${t("home_type_auto", L).replace("{detected}", detected)}</option>
|
||||
${regions.map((r) => html`<option value=${r.code} ?selected=${value === r.code}>${r.name}</option>`)}
|
||||
</select>
|
||||
</label>
|
||||
<div class="setting-hint">${t("home_region_hint", L)}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private async _setHomeRegion(value: string): Promise<void> {
|
||||
if (await this._updateSetting("home_region", value)) {
|
||||
// The region changes intervals and which checks exist — refetch.
|
||||
this._templatesRequested = false;
|
||||
await this._loadTemplates();
|
||||
}
|
||||
}
|
||||
|
||||
private async _setHomeType(value: string): Promise<void> {
|
||||
if (await this._updateSetting("home_type", value)) {
|
||||
// The dwelling drives the recommendations — refetch the profile.
|
||||
this._templatesRequested = false;
|
||||
await this._loadTemplates();
|
||||
}
|
||||
}
|
||||
|
||||
private _renderTemplateToggles(L: string) {
|
||||
this._loadTemplates();
|
||||
const hidden = new Set(this._settings!.disabled_template_ids || []);
|
||||
@@ -916,7 +1021,7 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
<span class="setting-label">${t("settings_row_actions", L)}</span>
|
||||
<select .value=${live(g.row_action_style || "buttons_compact")}
|
||||
@change=${(e: Event) => this._updateSetting("row_action_style", (e.target as HTMLSelectElement).value)}>
|
||||
${(["buttons_compact", "buttons", "icons"] as const).map((v) => html`
|
||||
${ROW_ACTION_STYLES.map((v) => html`
|
||||
<option value=${v} ?selected=${(g.row_action_style || "buttons_compact") === v}>${t(`row_actions_${v}`, L)}</option>`)}
|
||||
</select>
|
||||
</label>
|
||||
@@ -1336,8 +1441,8 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
</div>
|
||||
<label class="vac-field">
|
||||
<span class="filter-label">${t("vacation_buffer", L)}</span>
|
||||
<input type="number" min="0" max="14" .value=${String(this._vacBuffer)}
|
||||
@change=${(e: Event) => this._setVacationBuffer(parseInt((e.target as HTMLInputElement).value, 10) || 0)} />
|
||||
<input type="number" min=${VACATION_BUFFER_DAYS_RANGE[0]} max=${VACATION_BUFFER_DAYS_RANGE[1]} .value=${String(this._vacBuffer)}
|
||||
@change=${(e: Event) => this._setVacationBuffer(e)} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -1499,9 +1604,21 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
this._saveVacation(patch);
|
||||
}
|
||||
|
||||
private _setVacationBuffer(value: number): void {
|
||||
if (value < 0 || value > 14) return;
|
||||
this._saveVacation({ buffer_days: value });
|
||||
/** Out of range used to be dropped SILENTLY here while the Lovelace
|
||||
* vacation card sent it and showed the server's error — now both reject
|
||||
* it up front with the same message (DRY audit 2026-09-26). */
|
||||
private _setVacationBuffer(e: Event): void {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const [min, max] = VACATION_BUFFER_DAYS_RANGE;
|
||||
const v = Number(input.value);
|
||||
if (input.value.trim() !== "" && Number.isInteger(v) && v >= min && v <= max) {
|
||||
this._saveVacation({ buffer_days: v });
|
||||
return;
|
||||
}
|
||||
this._showToast(
|
||||
t("settings_value_out_of_range", this._lang).replace("{min}", String(min)).replace("{max}", String(max)),
|
||||
);
|
||||
input.value = String(this._vacBuffer);
|
||||
}
|
||||
|
||||
private _toggleVacationExempt(taskId: string, on: boolean): void {
|
||||
@@ -2001,24 +2118,23 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append("file", file, file.name);
|
||||
const resp = await fetch("/api/maintenance_supporter/documents/archive", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${this.hass.auth?.data?.access_token ?? ""}` },
|
||||
body: form,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
this._showToast(t("action_error", this._lang));
|
||||
} else {
|
||||
const result = (await resp.json()) as { blobs_written: number; documents_created: number };
|
||||
this._showToast(
|
||||
t("settings_docs_import_success", this._lang)
|
||||
.replace("{blobs}", String(result.blobs_written ?? 0))
|
||||
.replace("{docs}", String(result.documents_created ?? 0))
|
||||
);
|
||||
this.dispatchEvent(new CustomEvent("settings-changed"));
|
||||
}
|
||||
} catch {
|
||||
this._showToast(t("action_error", this._lang));
|
||||
const result = await postMultipart<{ blobs_written: number; documents_created: number }>(
|
||||
this.hass, "/api/maintenance_supporter/documents/archive", form, "docs_archive_too_large",
|
||||
);
|
||||
this._showToast(
|
||||
t("settings_docs_import_success", this._lang)
|
||||
.replace("{blobs}", String(result.blobs_written ?? 0))
|
||||
.replace("{docs}", String(result.documents_created ?? 0))
|
||||
);
|
||||
this.dispatchEvent(new CustomEvent("settings-changed"));
|
||||
} catch (e) {
|
||||
// A 413 names the limit; anything else stays the generic message.
|
||||
this._showToast(
|
||||
e instanceof Error && e.message === "docs_archive_too_large"
|
||||
? t("docs_archive_too_large", this._lang)
|
||||
.replace("{max}", `${formatNumber(DOCS_ARCHIVE_MAX_BYTES / (1024 * 1024), this._lang)} MB`)
|
||||
: t("action_error", this._lang),
|
||||
);
|
||||
}
|
||||
input.value = "";
|
||||
this._docArchiveLoading = false;
|
||||
@@ -2061,6 +2177,12 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
.home-climate-value { font-size: 14px; color: var(--secondary-text-color); text-align: right; }
|
||||
.home-traits { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
||||
.home-trait {
|
||||
font-size: 12px; padding: 3px 8px; border-radius: 12px;
|
||||
background: var(--secondary-background-color); color: var(--primary-text-color);
|
||||
}
|
||||
.section-desc {
|
||||
font-size: 13px;
|
||||
color: var(--secondary-text-color);
|
||||
@@ -2173,7 +2295,12 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
background: var(--card-background-color, #fff);
|
||||
color: var(--primary-text-color);
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
/* May shrink: a select is as wide as its longest option, and German
|
||||
"Buttons (auf dem Handy nur Symbole)" pushed the row 75 px past a
|
||||
360 px screen (overflow sweep 2026-09-27). */
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 60%;
|
||||
}
|
||||
|
||||
.settings-actions {
|
||||
|
||||
+33
-29
@@ -10,7 +10,7 @@ import { LitElement, html, css, nothing } from "lit";
|
||||
import { docDisplayName } from "../helpers/document-categories";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { t, ensureLocale, langOf } from "../styles";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { runWs } from "../helpers/ws-run";
|
||||
import { openSignedDocument } from "../helpers/document-url";
|
||||
import { isSafeHttpUrl } from "../helpers/url";
|
||||
import { formatBytes } from "../helpers/format-bytes";
|
||||
@@ -58,6 +58,9 @@ export class MaintenanceStorageSectionCard extends LitElement {
|
||||
|
||||
private _initiallyLoaded = false;
|
||||
private _searchTimer = 0;
|
||||
/** Only the newest query's answer may land: a slow reply for "fil" used to
|
||||
* overwrite the one for "filter" (bug audit 2026-09-26). */
|
||||
private _searchSeq = 0;
|
||||
|
||||
private get _lang(): string {
|
||||
return langOf(this.hass);
|
||||
@@ -75,18 +78,14 @@ export class MaintenanceStorageSectionCard extends LitElement {
|
||||
}
|
||||
|
||||
private async _load(): Promise<void> {
|
||||
this._busy = true;
|
||||
try {
|
||||
this._summary = await this.hass.connection.sendMessagePromise<StorageSummary>({
|
||||
type: "maintenance_supporter/documents/storage",
|
||||
});
|
||||
this._error = "";
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._loaded = true;
|
||||
this._busy = false;
|
||||
}
|
||||
const r = await runWs<StorageSummary>(this, { type: "maintenance_supporter/documents/storage" }, {
|
||||
busy: (b) => { this._busy = b; },
|
||||
onError: (m) => { this._error = m; },
|
||||
});
|
||||
this._loaded = true;
|
||||
if (r === undefined) return;
|
||||
this._summary = r;
|
||||
this._error = "";
|
||||
}
|
||||
|
||||
private _nameFor(objectId: string): string {
|
||||
@@ -121,20 +120,24 @@ export class MaintenanceStorageSectionCard extends LitElement {
|
||||
|
||||
private async _doSearch(): Promise<void> {
|
||||
const q = this._query.trim();
|
||||
const seq = ++this._searchSeq;
|
||||
if (!q) {
|
||||
this._results = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await this.hass.connection.sendMessagePromise<{ results: SearchResult[] }>({
|
||||
type: "maintenance_supporter/documents/search",
|
||||
query: q,
|
||||
});
|
||||
this._results = r.results || [];
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
let failure = "";
|
||||
const r = await runWs<{ results: SearchResult[] }>(
|
||||
this,
|
||||
{ type: "maintenance_supporter/documents/search", query: q },
|
||||
{ onError: (m) => { failure = m; } },
|
||||
);
|
||||
if (seq !== this._searchSeq) return;
|
||||
if (r === undefined) {
|
||||
this._error = failure;
|
||||
this._results = [];
|
||||
return;
|
||||
}
|
||||
this._results = r?.results || [];
|
||||
}
|
||||
|
||||
private async _openResult(doc: SearchResult): Promise<void> {
|
||||
@@ -145,11 +148,7 @@ export class MaintenanceStorageSectionCard extends LitElement {
|
||||
if (isSafeHttpUrl(doc.url)) window.open(doc.url, "_blank", "noopener");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await openSignedDocument(this.hass, doc.id);
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
}
|
||||
await runWs(this, () => openSignedDocument(this.hass, doc.id), { onError: (m) => { this._error = m; } });
|
||||
}
|
||||
|
||||
private _renderResult(doc: SearchResult, L: string) {
|
||||
@@ -320,16 +319,21 @@ export class MaintenanceStorageSectionCard extends LitElement {
|
||||
.result-open { color: var(--secondary-text-color, #888); --mdc-icon-size: 18px; flex: none; }
|
||||
.search-empty { color: var(--secondary-text-color, #888); font-size: 13px; padding: 8px 2px; }
|
||||
.header { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
/* Phone width (2026-09-27): the size moves to a line of its own before
|
||||
it could slide under the refresh button; only the title shortens. */
|
||||
.toggle {
|
||||
display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0;
|
||||
display: flex; flex-wrap: wrap; align-items: center; gap: 2px 8px; flex: 1; min-width: 0;
|
||||
background: none; border: none; padding: 4px 0; margin: 0; cursor: pointer;
|
||||
font: inherit; color: var(--primary-text-color); text-align: left;
|
||||
}
|
||||
.toggle:focus-visible { outline: 2px solid var(--primary-color); outline-offset: 2px; border-radius: 6px; }
|
||||
.chevron { --mdc-icon-size: 22px; color: var(--secondary-text-color, #888); flex: none; }
|
||||
.title-text { font-size: 16px; font-weight: 500; }
|
||||
.title-text {
|
||||
font-size: 16px; font-weight: 500; min-width: 0;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.header-summary {
|
||||
margin-left: auto; display: flex; align-items: center; gap: 8px;
|
||||
margin-left: auto; display: flex; align-items: center; gap: 8px; flex: none;
|
||||
font-size: 14px; font-weight: 600; white-space: nowrap;
|
||||
}
|
||||
.header-summary .saved { color: var(--success-color, #4caf50); font-weight: 500; }
|
||||
|
||||
+208
-135
@@ -6,13 +6,21 @@
|
||||
* every task arrives with its sensor threshold trigger PRE-WIRED (below N
|
||||
* hours left / below N % remaining, auto-resolving on replacement). The wiring
|
||||
* comes from the server-side source-verified catalog — never from this client.
|
||||
*
|
||||
* 2.94 (duplicates in a real home: the washer and dryer adopted as NEW objects
|
||||
* next to the user's own, a second wallbox, lubrication tasks under other
|
||||
* names): the target defaults to the existing object the device most likely
|
||||
* is, every task has its own tick box, a task the target probably already has
|
||||
* under another name starts unticked with that name, tasks it has by name are
|
||||
* listed as already there, and a before/after line says what adopting does.
|
||||
* Picking another target asks the server again (integration_setups/preview).
|
||||
*/
|
||||
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
|
||||
import { t, ensureLocale, langOf } from "../styles";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { runWs } from "../helpers/ws-run";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
interface SetupTask {
|
||||
@@ -21,6 +29,7 @@ interface SetupTask {
|
||||
entity_ids: string[];
|
||||
threshold: number;
|
||||
direction: string;
|
||||
covered_by?: { task_id: string; name: string; reason: string } | null;
|
||||
}
|
||||
|
||||
interface SuggestedSetup {
|
||||
@@ -31,7 +40,11 @@ interface SuggestedSetup {
|
||||
integration_name: string;
|
||||
suggested_entry_id: string | null;
|
||||
suggested_object_name: string;
|
||||
candidate?: { entry_id: string; name: string; reasons: string[] } | null;
|
||||
target_entry_id?: string | null;
|
||||
target_task_count?: number;
|
||||
tasks: SetupTask[];
|
||||
already?: Array<{ task_name: string; task_name_localized: string; existing_name: string }>;
|
||||
}
|
||||
|
||||
interface AdoptResponse {
|
||||
@@ -41,6 +54,9 @@ interface AdoptResponse {
|
||||
errors?: unknown[];
|
||||
}
|
||||
|
||||
/** The target select's value for "a new object for this device". */
|
||||
const NEW = "__new__";
|
||||
|
||||
export class MaintenanceSuggestedSetupsDialog extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@@ -53,9 +69,10 @@ export class MaintenanceSuggestedSetupsDialog extends LitElement {
|
||||
// #102: optional counting start values, keyed "deviceId taskName".
|
||||
// Only usage_delta duties render the input; raw strings until adopt.
|
||||
@state() private _baselines: Map<string, string> = new Map();
|
||||
// #105: adopt target per device — entry_id of an existing object, or ""
|
||||
// for the default (the suggested bound object, else a new object).
|
||||
// Adopt target per device: an object's entry_id, or NEW.
|
||||
@state() private _targets: Map<string, string> = new Map();
|
||||
// Ticked tasks per device (task_name keys).
|
||||
@state() private _tasks: Map<string, Set<string>> = new Map();
|
||||
@state() private _objects: Array<{ entry_id: string; name: string }> = [];
|
||||
|
||||
private _localeReady = false;
|
||||
@@ -71,35 +88,41 @@ export class MaintenanceSuggestedSetupsDialog extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
/** Ticked by default: everything the target does not probably have yet. */
|
||||
private _defaultTicks(setup: SuggestedSetup): Set<string> {
|
||||
return new Set(setup.tasks.filter((task) => !task.covered_by).map((task) => task.task_name));
|
||||
}
|
||||
|
||||
public async open(): Promise<void> {
|
||||
this._open = true;
|
||||
this._loading = true;
|
||||
this._error = "";
|
||||
this._setups = [];
|
||||
this._selected = new Set();
|
||||
try {
|
||||
const resp = await this.hass.connection.sendMessagePromise<{ setups: SuggestedSetup[] }>({
|
||||
type: "maintenance_supporter/integration_setups/discover",
|
||||
});
|
||||
this._setups = resp.setups || [];
|
||||
const resp = await runWs<{ setups: SuggestedSetup[] }>(
|
||||
this,
|
||||
{ type: "maintenance_supporter/integration_setups/discover" },
|
||||
{ onError: (m) => { this._error = m; } },
|
||||
);
|
||||
if (resp !== undefined) {
|
||||
this._setups = resp?.setups || [];
|
||||
this._selected = new Set(this._setups.map((s) => s.device_id));
|
||||
this._baselines = new Map();
|
||||
this._targets = new Map();
|
||||
this._targets = new Map(this._setups.map((s) => [s.device_id, s.target_entry_id || NEW]));
|
||||
this._tasks = new Map(this._setups.map((s) => [s.device_id, this._defaultTicks(s)]));
|
||||
try {
|
||||
const objs = await this.hass.connection.sendMessagePromise<{
|
||||
objects: Array<{ entry_id: string; object: { name: string } }>;
|
||||
objects: Array<{ entry_id: string; object: { name: string; archived_at?: string | null } }>;
|
||||
}>({ type: "maintenance_supporter/objects" });
|
||||
this._objects = (objs.objects || [])
|
||||
.filter((o) => !o.object?.archived_at)
|
||||
.map((o) => ({ entry_id: o.entry_id, name: o.object?.name || o.entry_id }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
} catch {
|
||||
this._objects = []; // picker degrades to the default target only
|
||||
}
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
this._loading = false;
|
||||
}
|
||||
|
||||
private _close(): void {
|
||||
@@ -113,43 +136,110 @@ export class MaintenanceSuggestedSetupsDialog extends LitElement {
|
||||
this._selected = next;
|
||||
};
|
||||
|
||||
private _toggleTask(deviceId: string, taskName: string): void {
|
||||
const ticks = new Set(this._tasks.get(deviceId) ?? []);
|
||||
if (ticks.has(taskName)) ticks.delete(taskName);
|
||||
else ticks.add(taskName);
|
||||
this._tasks = new Map(this._tasks).set(deviceId, ticks);
|
||||
}
|
||||
|
||||
/** Another target: the server judges the proposals against it again. */
|
||||
private async _retarget(setup: SuggestedSetup, value: string): Promise<void> {
|
||||
this._targets = new Map(this._targets).set(setup.device_id, value);
|
||||
const fresh = await runWs<SuggestedSetup>(
|
||||
this,
|
||||
{
|
||||
type: "maintenance_supporter/integration_setups/preview",
|
||||
device_id: setup.device_id,
|
||||
entry_id: value === NEW ? null : value,
|
||||
},
|
||||
{ onError: (m) => { this._error = m; } },
|
||||
);
|
||||
// An empty answer (an older server without preview) keeps the old ticks.
|
||||
if (!fresh || !Array.isArray(fresh.tasks)) return;
|
||||
this._setups = this._setups.map((s) => (s.device_id === setup.device_id ? { ...s, ...fresh } : s));
|
||||
this._tasks = new Map(this._tasks).set(setup.device_id, this._defaultTicks(fresh));
|
||||
}
|
||||
|
||||
private _adopt = async (): Promise<void> => {
|
||||
if (this._selected.size === 0 || this._adopting) return;
|
||||
this._adopting = true;
|
||||
if (this._adopting) return;
|
||||
this._error = "";
|
||||
try {
|
||||
const result = await this.hass.connection.sendMessagePromise<AdoptResponse>({
|
||||
type: "maintenance_supporter/integration_setups/adopt",
|
||||
selections: [...this._selected].map((device_id) => {
|
||||
const sel: { device_id: string; entry_id?: string; baselines?: Record<string, number> } = {
|
||||
device_id,
|
||||
};
|
||||
const target = this._targets.get(device_id);
|
||||
if (target) sel.entry_id = target;
|
||||
const setup = this._setups.find((s) => s.device_id === device_id);
|
||||
for (const task of setup?.tasks ?? []) {
|
||||
const raw = this._baselines.get(`${device_id} ${task.task_name}`);
|
||||
const b = raw ? parseFloat(raw) : NaN;
|
||||
if (!isNaN(b) && b >= 0) (sel.baselines ??= {})[task.task_name] = b;
|
||||
}
|
||||
return sel;
|
||||
}),
|
||||
});
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("integration-setups-adopted", {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
detail: result,
|
||||
}),
|
||||
);
|
||||
this._open = false;
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._adopting = false;
|
||||
}
|
||||
const selections = [...this._selected]
|
||||
.map((device_id) => {
|
||||
const ticks = [...(this._tasks.get(device_id) ?? [])];
|
||||
const sel: { device_id: string; entry_id?: string; task_names: string[]; baselines?: Record<string, number> } = {
|
||||
device_id,
|
||||
task_names: ticks,
|
||||
};
|
||||
const target = this._targets.get(device_id);
|
||||
if (target && target !== NEW) sel.entry_id = target;
|
||||
for (const name of ticks) {
|
||||
const raw = this._baselines.get(`${device_id} ${name}`);
|
||||
const b = raw ? parseFloat(raw) : NaN;
|
||||
if (!isNaN(b) && b >= 0) (sel.baselines ??= {})[name] = b;
|
||||
}
|
||||
return sel;
|
||||
})
|
||||
.filter((sel) => sel.task_names.length > 0);
|
||||
if (selections.length === 0) return;
|
||||
const result = await runWs<AdoptResponse>(
|
||||
this,
|
||||
{ type: "maintenance_supporter/integration_setups/adopt", selections },
|
||||
{ busy: (b) => { this._adopting = b; }, onError: (m) => { this._error = m; } },
|
||||
);
|
||||
if (result === undefined) return;
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("integration-setups-adopted", {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
detail: result,
|
||||
}),
|
||||
);
|
||||
this._open = false;
|
||||
};
|
||||
|
||||
private get _anythingTicked(): boolean {
|
||||
return [...this._selected].some((id) => (this._tasks.get(id)?.size ?? 0) > 0);
|
||||
}
|
||||
|
||||
private _targetOptions(s: SuggestedSetup, L: string) {
|
||||
const current = this._targets.get(s.device_id) ?? NEW;
|
||||
const options: Array<{ value: string; label: string }> = [];
|
||||
if (s.suggested_entry_id) {
|
||||
options.push({ value: s.suggested_entry_id, label: s.suggested_object_name });
|
||||
} else {
|
||||
if (s.candidate) {
|
||||
const reasons = s.candidate.reasons.map((r) => t(`setups_reason_${r}`, L)).join(", ");
|
||||
options.push({
|
||||
value: s.candidate.entry_id,
|
||||
label: t("setups_target_match", L).replace("{name}", s.candidate.name).replace("{reasons}", reasons),
|
||||
});
|
||||
}
|
||||
options.push({ value: NEW, label: t("setups_target_new", L).replace("{name}", s.device_name) });
|
||||
}
|
||||
const listed = new Set(options.map((o) => o.value));
|
||||
for (const o of this._objects) if (!listed.has(o.entry_id)) options.push({ value: o.entry_id, label: o.name });
|
||||
return html`
|
||||
<select
|
||||
class="target-select"
|
||||
@click=${(e: Event) => e.stopPropagation()}
|
||||
@change=${(e: Event) => void this._retarget(s, (e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
${options.map((o) => html`<option value=${o.value} ?selected=${o.value === current}>${o.label}</option>`)}
|
||||
</select>
|
||||
`;
|
||||
}
|
||||
|
||||
private _beforeAfter(s: SuggestedSetup, L: string): string {
|
||||
const ticked = this._tasks.get(s.device_id)?.size ?? 0;
|
||||
const target = this._targets.get(s.device_id) ?? NEW;
|
||||
if (target === NEW) {
|
||||
return t("setups_new_object_count", L).replace("{name}", s.device_name).replace("{count}", String(ticked));
|
||||
}
|
||||
const before = s.target_task_count ?? 0;
|
||||
return t("setups_before_after", L).replace("{before}", String(before)).replace("{after}", String(before + ticked));
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this._open) return html``;
|
||||
const L = this._lang;
|
||||
@@ -170,10 +260,12 @@ export class MaintenanceSuggestedSetupsDialog extends LitElement {
|
||||
${this._setups.map((s) => {
|
||||
const checked = this._selected.has(s.device_id);
|
||||
const sub = [s.integration_name, s.area_name].filter(Boolean).join(" · ");
|
||||
const ticks = this._tasks.get(s.device_id) ?? new Set<string>();
|
||||
return html`
|
||||
<label class="row">
|
||||
<div class="row ${checked ? "" : "off"}" data-device=${s.device_id}>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="device-check"
|
||||
.checked=${checked}
|
||||
@change=${() => this._toggle(s.device_id)}
|
||||
/>
|
||||
@@ -182,78 +274,60 @@ export class MaintenanceSuggestedSetupsDialog extends LitElement {
|
||||
<span class="row-name">${s.device_name}</span>
|
||||
</div>
|
||||
<div class="row-sub">${sub}</div>
|
||||
<div class="row-target" @click=${(e: Event) => e.preventDefault()}>
|
||||
→
|
||||
${checked && this._objects.length > 0
|
||||
? html`
|
||||
<select
|
||||
class="target-select"
|
||||
@change=${(e: Event) => {
|
||||
const next = new Map(this._targets);
|
||||
const v = (e.target as HTMLSelectElement).value;
|
||||
if (v) next.set(s.device_id, v);
|
||||
else next.delete(s.device_id);
|
||||
this._targets = next;
|
||||
}}
|
||||
>
|
||||
<option value="" ?selected=${!this._targets.get(s.device_id)}>
|
||||
${s.suggested_entry_id
|
||||
? s.suggested_object_name
|
||||
: t("setups_target_new", L).replace("{name}", s.suggested_object_name)}
|
||||
</option>
|
||||
${this._objects
|
||||
.filter((o) => o.entry_id !== s.suggested_entry_id)
|
||||
.map(
|
||||
(o) => html`<option
|
||||
value=${o.entry_id}
|
||||
?selected=${this._targets.get(s.device_id) === o.entry_id}
|
||||
>
|
||||
${o.name}
|
||||
</option>`,
|
||||
)}
|
||||
</select>
|
||||
`
|
||||
: html`${s.suggested_object_name}${s.suggested_entry_id
|
||||
? nothing
|
||||
: html` <span class="new-tag">${t("adopt_problem_new_object", L)}</span>`}`}
|
||||
</div>
|
||||
<div class="row-tasks">
|
||||
${s.tasks.map(
|
||||
(task) => html`<span class="chip" title=${task.entity_ids.join(", ")}>
|
||||
<ha-icon icon="mdi:link-variant"></ha-icon>${task.task_name_localized ||
|
||||
task.task_name}
|
||||
</span>`,
|
||||
)}
|
||||
</div>
|
||||
<div class="row-target">→ ${this._targetOptions(s, L)}</div>
|
||||
${checked
|
||||
? s.tasks
|
||||
.filter((task) => task.direction === "usage_delta")
|
||||
.map((task) => {
|
||||
const key = `${s.device_id} ${task.task_name}`;
|
||||
return html`
|
||||
<div class="baseline-field" @click=${(e: Event) => e.preventDefault()}>
|
||||
<span class="baseline-label"
|
||||
>${task.task_name_localized || task.task_name} —
|
||||
${t("setups_baseline_hint", L)}</span
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
min="0"
|
||||
.value=${this._baselines.get(key) ?? ""}
|
||||
@click=${(e: Event) => e.preventDefault()}
|
||||
@input=${(e: Event) => {
|
||||
const next = new Map(this._baselines);
|
||||
next.set(key, (e.target as HTMLInputElement).value);
|
||||
this._baselines = next;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
? html`
|
||||
<div class="task-list">
|
||||
${s.tasks.map(
|
||||
(task) => html`
|
||||
<label class="task" title=${task.entity_ids.join(", ")}>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="task-check"
|
||||
.checked=${ticks.has(task.task_name)}
|
||||
@change=${() => this._toggleTask(s.device_id, task.task_name)}
|
||||
/>
|
||||
<span class="task-text">
|
||||
<span>${task.task_name_localized || task.task_name}</span>
|
||||
${task.covered_by
|
||||
? html`<span class="maybe">${t("setups_maybe_covered", L).replace("{name}", task.covered_by.name)}</span>`
|
||||
: nothing}
|
||||
</span>
|
||||
</label>
|
||||
${task.direction === "usage_delta" && ticks.has(task.task_name)
|
||||
? html`
|
||||
<div class="baseline-field">
|
||||
<span class="baseline-label">${t("setups_baseline_hint", L)}</span>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
min="0"
|
||||
.value=${this._baselines.get(`${s.device_id} ${task.task_name}`) ?? ""}
|
||||
@input=${(e: Event) => {
|
||||
const next = new Map(this._baselines);
|
||||
next.set(`${s.device_id} ${task.task_name}`, (e.target as HTMLInputElement).value);
|
||||
this._baselines = next;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
`,
|
||||
)}
|
||||
${s.already?.length
|
||||
? html`<div class="already">
|
||||
${t("setups_already", L).replace(
|
||||
"{names}",
|
||||
s.already.map((a) => a.existing_name).join(", "),
|
||||
)}
|
||||
</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
<div class="before-after">${this._beforeAfter(s, L)}</div>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
@@ -263,10 +337,7 @@ export class MaintenanceSuggestedSetupsDialog extends LitElement {
|
||||
<ha-button appearance="plain" @click=${this._close}>
|
||||
${t("cancel", L)}
|
||||
</ha-button>
|
||||
<ha-button
|
||||
@click=${this._adopt}
|
||||
.disabled=${this._selected.size === 0 || this._adopting}
|
||||
>
|
||||
<ha-button @click=${this._adopt} .disabled=${!this._anythingTicked || this._adopting}>
|
||||
${t("setups_adopt", L)}
|
||||
</ha-button>
|
||||
</div>
|
||||
@@ -304,33 +375,35 @@ export class MaintenanceSuggestedSetupsDialog extends LitElement {
|
||||
.hint { color: var(--secondary-text-color); font-size: 13px; }
|
||||
.error { color: var(--error-color, #f44336); font-size: 13px; }
|
||||
.loading, .empty { color: var(--secondary-text-color); font-size: 14px; padding: 12px 0; }
|
||||
.list { display: flex; flex-direction: column; gap: 6px; overflow-y: auto; max-height: 50vh; }
|
||||
.list { display: flex; flex-direction: column; gap: 6px; overflow-y: auto; max-height: 55vh; }
|
||||
.row {
|
||||
display: flex; align-items: flex-start; gap: 10px; padding: 8px;
|
||||
border: 1px solid var(--divider-color); border-radius: 6px; cursor: pointer;
|
||||
border: 1px solid var(--divider-color); border-radius: 6px;
|
||||
}
|
||||
.row.off .row-main { opacity: 0.6; }
|
||||
.row input { margin-top: 2px; cursor: pointer; }
|
||||
.row-main { display: flex; flex-direction: column; gap: 3px; min-width: 0; flex: 1; }
|
||||
.row-main { display: flex; flex-direction: column; gap: 4px; min-width: 0; flex: 1; }
|
||||
.row-name { font-weight: 500; font-size: 13px; }
|
||||
.row-sub, .row-target { color: var(--secondary-text-color); font-size: 12px; }
|
||||
.new-tag { font-style: italic; }
|
||||
.row-tasks { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 2px; }
|
||||
.chip {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
font-size: 11px; padding: 2px 8px; border-radius: 10px;
|
||||
background: var(--secondary-background-color, rgba(0, 0, 0, 0.06));
|
||||
color: var(--primary-text-color); white-space: nowrap;
|
||||
}
|
||||
.chip ha-icon { --mdc-icon-size: 12px; color: var(--primary-color); }
|
||||
.row-target { display: flex; align-items: center; gap: 6px; min-width: 0; }
|
||||
.target-select {
|
||||
font-size: 12px; padding: 2px 4px; max-width: 100%;
|
||||
font-size: 12px; padding: 2px 4px; min-width: 0; max-width: 100%; flex: 1 1 auto;
|
||||
border: 1px solid var(--divider-color); border-radius: 4px;
|
||||
background: var(--card-background-color, #fff);
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
.task-list { display: flex; flex-direction: column; gap: 2px; margin-top: 2px; }
|
||||
.task { display: flex; align-items: flex-start; gap: 6px; font-size: 13px; cursor: pointer; }
|
||||
.task-text { display: flex; flex-direction: column; min-width: 0; }
|
||||
.maybe { font-size: 11px; color: var(--warning-color, #ff9800); }
|
||||
.already { font-size: 11px; color: var(--secondary-text-color); margin-top: 2px; }
|
||||
.before-after {
|
||||
font-size: 12px; font-weight: 500; color: var(--primary-color);
|
||||
border-top: 1px dashed var(--divider-color); padding-top: 4px; margin-top: 2px;
|
||||
}
|
||||
.baseline-field {
|
||||
display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
|
||||
margin-top: 4px; font-size: 12px; color: var(--secondary-text-color);
|
||||
margin: 0 0 2px 24px; font-size: 12px; color: var(--secondary-text-color);
|
||||
}
|
||||
.baseline-field input {
|
||||
width: 110px; padding: 3px 6px; font-size: 12px;
|
||||
|
||||
@@ -13,8 +13,16 @@ import {
|
||||
TRIGGER_PICKER_DOMAINS,
|
||||
} from "../helpers/trigger-domains";
|
||||
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { runWs } from "../helpers/ws-run";
|
||||
import {
|
||||
EWA_ALPHA_RANGE,
|
||||
PART_QTY_RANGE,
|
||||
SCHEDULE_OFFSET_MAX_DAYS,
|
||||
TRIGGER_FOR_MINUTES_RANGE,
|
||||
TRIGGER_TARGET_CHANGES_RANGE,
|
||||
WARNING_DAYS_RANGE,
|
||||
} from "../helpers/setting-ranges";
|
||||
import { parseDurationMinutes } from "../helpers/duration";
|
||||
import type { TriggerType } from "../types";
|
||||
import { REQUIRED_COMPLETION_KEYS, REQUIRED_COMPLETION_LABELS } from "./required-completion-labels";
|
||||
import "./ms-textfield";
|
||||
@@ -37,6 +45,10 @@ const TRIGGER_TYPE_KEYS = ["threshold", "counter", "state_change", "runtime"];
|
||||
const TRIGGER_TYPE_KEYS_WITH_COMPOUND = [...TRIGGER_TYPE_KEYS, "compound"];
|
||||
// Backend defaults for the adaptive tuning fields (ewa_alpha / min / max days).
|
||||
const ADAPTIVE_DEFAULTS = { alpha: "0.3", min: "7", max: "365" } as const;
|
||||
// Bounds of a consumes_parts quantity (the field's min/max, the same range
|
||||
// the completion's used_parts accept) — validated on save with a message,
|
||||
// never silently replaced. One TS source, pinned to Python.
|
||||
const CONSUMES_QTY_RANGE = PART_QTY_RANGE;
|
||||
|
||||
/** One condition of a compound trigger — a flat trigger the user edits inline.
|
||||
* String-typed like the top-level fields (form inputs); coerced on save. */
|
||||
@@ -80,11 +92,17 @@ const MANAGED_CONDITION_KEYS = new Set([
|
||||
"trigger_runtime_hours", "trigger_on_states",
|
||||
]);
|
||||
|
||||
/** Map a persisted compound condition (storage shape) to an editable draft. */
|
||||
function conditionToDraft(c: TriggerConfig): CompoundConditionDraft {
|
||||
const ids = c.entity_ids || (c.entity_id ? [c.entity_id] : []);
|
||||
/** The per-type trigger fields BOTH editors own — the flat trigger form and
|
||||
* every compound condition. The flat form used to hydrate and serialize
|
||||
* them with its own copy of the rules below, and the copies had drifted:
|
||||
* a compound state_change condition dropped its #136 hold time
|
||||
* (`trigger_for_minutes`, a managed key, so not carried either) on every
|
||||
* save (DRY audit 2026-09-26). */
|
||||
export type TriggerTypeFields = Omit<CompoundConditionDraft, "entityIds" | "carry">;
|
||||
|
||||
/** Storage → form for the shared per-type fields. */
|
||||
export function typeFieldsFromConfig(c: TriggerConfig): TriggerTypeFields {
|
||||
return {
|
||||
entityIds: ids.join(", "),
|
||||
type: c.type || "threshold",
|
||||
attribute: c.attribute || "",
|
||||
above: c.trigger_above?.toString() ?? "",
|
||||
@@ -99,6 +117,42 @@ function conditionToDraft(c: TriggerConfig): CompoundConditionDraft {
|
||||
targetChanges: c.trigger_target_changes?.toString() ?? "",
|
||||
runtimeHours: c.trigger_runtime_hours?.toString() ?? "",
|
||||
onStates: (c.trigger_on_states || []).join(", "),
|
||||
};
|
||||
}
|
||||
|
||||
/** Form → storage: writes the shared per-type fields onto `c` (only the
|
||||
* ones the type uses; unparsable numbers are left out). */
|
||||
export function applyTypeFields(c: TriggerConfig, d: TriggerTypeFields): void {
|
||||
if (d.attribute) c.attribute = d.attribute;
|
||||
const forMinutes = parseInt(d.forMinutes, 10);
|
||||
if (d.type === "threshold") {
|
||||
const a = parseFloat(d.above); if (!isNaN(a)) c.trigger_above = a;
|
||||
const b = parseFloat(d.below); if (!isNaN(b)) c.trigger_below = b;
|
||||
const eq = parseFloat(d.equals); if (!isNaN(eq)) c.trigger_equals = eq;
|
||||
const ne = parseFloat(d.notEquals); if (!isNaN(ne)) c.trigger_not_equals = ne;
|
||||
if (!isNaN(forMinutes)) c.trigger_for_minutes = forMinutes;
|
||||
} else if (d.type === "counter") {
|
||||
const v = parseFloat(d.targetValue); if (!isNaN(v)) c.trigger_target_value = v;
|
||||
c.trigger_delta_mode = d.deltaMode;
|
||||
} else if (d.type === "state_change") {
|
||||
if (d.fromState) c.trigger_from_state = d.fromState;
|
||||
if (d.toState) c.trigger_to_state = d.toState;
|
||||
const n = parseInt(d.targetChanges, 10); if (!isNaN(n)) c.trigger_target_changes = n;
|
||||
// #136: the new state must hold this long before a change counts.
|
||||
if (!isNaN(forMinutes)) c.trigger_for_minutes = forMinutes;
|
||||
} else if (d.type === "runtime") {
|
||||
const h = parseFloat(d.runtimeHours); if (!isNaN(h)) c.trigger_runtime_hours = h;
|
||||
const on = (d.onStates || "").split(",").map((s) => s.trim()).filter(Boolean);
|
||||
if (on.length > 0) c.trigger_on_states = on;
|
||||
}
|
||||
}
|
||||
|
||||
/** Map a persisted compound condition (storage shape) to an editable draft. */
|
||||
function conditionToDraft(c: TriggerConfig): CompoundConditionDraft {
|
||||
const ids = c.entity_ids || (c.entity_id ? [c.entity_id] : []);
|
||||
return {
|
||||
entityIds: ids.join(", "),
|
||||
...typeFieldsFromConfig(c),
|
||||
carry: Object.fromEntries(
|
||||
Object.entries(c).filter(([k]) => !MANAGED_CONDITION_KEYS.has(k) && !k.startsWith("_")),
|
||||
) as Partial<TriggerConfig>,
|
||||
@@ -111,25 +165,7 @@ function draftToCondition(d: CompoundConditionDraft): TriggerConfig | null {
|
||||
const ids = d.entityIds.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
if (ids.length === 0) return null;
|
||||
const c: TriggerConfig = { ...(d.carry || {}), entity_id: ids[0], entity_ids: ids, type: d.type };
|
||||
if (d.attribute) c.attribute = d.attribute;
|
||||
if (d.type === "threshold") {
|
||||
const a = parseFloat(d.above); if (!isNaN(a)) c.trigger_above = a;
|
||||
const b = parseFloat(d.below); if (!isNaN(b)) c.trigger_below = b;
|
||||
const eq = parseFloat(d.equals); if (!isNaN(eq)) c.trigger_equals = eq;
|
||||
const ne = parseFloat(d.notEquals); if (!isNaN(ne)) c.trigger_not_equals = ne;
|
||||
const f = parseInt(d.forMinutes, 10); if (!isNaN(f)) c.trigger_for_minutes = f;
|
||||
} else if (d.type === "counter") {
|
||||
const v = parseFloat(d.targetValue); if (!isNaN(v)) c.trigger_target_value = v;
|
||||
c.trigger_delta_mode = d.deltaMode;
|
||||
} else if (d.type === "state_change") {
|
||||
if (d.fromState) c.trigger_from_state = d.fromState;
|
||||
if (d.toState) c.trigger_to_state = d.toState;
|
||||
const n = parseInt(d.targetChanges, 10); if (!isNaN(n)) c.trigger_target_changes = n;
|
||||
} else if (d.type === "runtime") {
|
||||
const h = parseFloat(d.runtimeHours); if (!isNaN(h)) c.trigger_runtime_hours = h;
|
||||
const on = (d.onStates || "").split(",").map((s) => s.trim()).filter(Boolean);
|
||||
if (on.length > 0) c.trigger_on_states = on;
|
||||
}
|
||||
applyTypeFields(c, d);
|
||||
return c;
|
||||
}
|
||||
|
||||
@@ -277,11 +313,22 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
/** 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> = {};
|
||||
/** The TYPED quantity per picked part (same key), parsed on save. The
|
||||
* field wrote back a parsed number on every keystroke — clearing "2" to
|
||||
* type "3" snapped to "1" first, giving "13" (bug audit 2026-09-26 #2;
|
||||
* the complete dialog's `_usedQtyText` got the same fix). */
|
||||
@state() private _consumesQtyText: Record<string, string> = {};
|
||||
@state() private _partsLoadFailed = false;
|
||||
@state() private _availableTags: Array<{id: string; name: string}> = [];
|
||||
|
||||
// User assignment
|
||||
@state() private _responsibleUserId: string | null = null;
|
||||
// What an edit loaded for the two LIVE fields other surfaces change while
|
||||
// the dialog is open — a completion (NFC, voice, another user) moves the
|
||||
// anchor and rotates the assignee. Sent back only when edited here, else
|
||||
// Save rolled that completion back (bug audit 2026-09-26).
|
||||
private _loadedLastPerformed = "";
|
||||
private _loadedResponsibleUserId: string | null = null;
|
||||
@state() private _assigneePool: string[] = [];
|
||||
@state() private _rotationStrategy = "";
|
||||
@state() private _availableUsers: HAUser[] = [];
|
||||
@@ -435,6 +482,7 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
this._mirrorTodoEntities = [...(task.mirror_todo_entities || [])];
|
||||
this._enabled = task.enabled !== false;
|
||||
this._lastPerformed = task.last_performed || "";
|
||||
this._loadedLastPerformed = this._lastPerformed;
|
||||
this._nfcTagId = task.nfc_tag_id || "";
|
||||
this._requireTagScan = !!task.require_tag_scan;
|
||||
this._allowSkip = task.allow_skip !== false;
|
||||
@@ -446,7 +494,9 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
this._consumesParts = Object.fromEntries(
|
||||
(task.consumes_parts || []).map((l) => [partLinkKey(l), { ...l }]),
|
||||
);
|
||||
this._consumesQtyText = {};
|
||||
this._responsibleUserId = task.responsible_user_id || null;
|
||||
this._loadedResponsibleUserId = this._responsibleUserId;
|
||||
this._assigneePool = [...(task.assignee_pool || [])];
|
||||
this._rotationStrategy = task.rotation_strategy || "";
|
||||
|
||||
@@ -524,25 +574,13 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
this._triggerEntityId = tc.entity_id || (tc.entity_ids && tc.entity_ids[0]) || "";
|
||||
this._triggerEntityIds = tc.entity_ids || (tc.entity_id ? [tc.entity_id] : []);
|
||||
this._triggerEntityLogic = tc.entity_logic || "any";
|
||||
this._triggerAttribute = tc.attribute || "";
|
||||
this._triggerType = tc.type || "threshold";
|
||||
this._triggerAbove = tc.trigger_above?.toString() || "";
|
||||
this._triggerBelow = tc.trigger_below?.toString() || "";
|
||||
this._triggerEquals = tc.trigger_equals?.toString() || "";
|
||||
this._triggerNotEquals = tc.trigger_not_equals?.toString() || "";
|
||||
this._triggerForMinutes = tc.trigger_for_minutes?.toString() || "0";
|
||||
this._setTypeFields(typeFieldsFromConfig(tc));
|
||||
this._triggerCombinator = tc.trigger_combinator === "all" ? "all" : "any";
|
||||
this._triggerTargetValue = tc.trigger_target_value?.toString() || "";
|
||||
this._triggerDeltaMode = tc.trigger_delta_mode || false;
|
||||
// Flat-form-only fields (a compound condition carries them via `carry`).
|
||||
this._triggerBaselineValue = tc.trigger_baseline_value?.toString() || "";
|
||||
this._liveBaselineValue = task.trigger_baseline_value ?? null;
|
||||
this._autoCompleteOnRecovery = tc.auto_complete_on_recovery || false;
|
||||
this._triggerFromState = tc.trigger_from_state || "";
|
||||
this._triggerToState = tc.trigger_to_state || "";
|
||||
this._triggerTargetChanges = tc.trigger_target_changes?.toString() || "";
|
||||
this._triggerRuntimeHours = tc.trigger_runtime_hours?.toString() || "";
|
||||
this._triggerRuntimeMaxSession = tc.trigger_runtime_max_session_seconds?.toString() || "";
|
||||
this._triggerOnStates = (tc.trigger_on_states || []).join(", ");
|
||||
if (tc.type === "compound") {
|
||||
this._compoundLogic = tc.compound_logic === "OR" ? "OR" : "AND";
|
||||
this._compoundConditions = (tc.conditions || []).map(conditionToDraft);
|
||||
@@ -599,6 +637,7 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
this._readingUnit = "";
|
||||
this._readings = [];
|
||||
this._consumesParts = {};
|
||||
this._consumesQtyText = {};
|
||||
this._responsibleUserId = null;
|
||||
this._assigneePool = [];
|
||||
this._rotationStrategy = "";
|
||||
@@ -633,32 +672,58 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
this._resetTriggerFields();
|
||||
}
|
||||
|
||||
/** The flat trigger form's shared per-type fields as one struct — the
|
||||
* same shape a compound condition draft edits (typeFieldsFromConfig /
|
||||
* applyTypeFields are the one rule set for both). */
|
||||
private _typeFields(): TriggerTypeFields {
|
||||
return {
|
||||
type: this._triggerType,
|
||||
attribute: this._triggerAttribute,
|
||||
above: this._triggerAbove,
|
||||
below: this._triggerBelow,
|
||||
equals: this._triggerEquals,
|
||||
notEquals: this._triggerNotEquals,
|
||||
forMinutes: this._triggerForMinutes,
|
||||
targetValue: this._triggerTargetValue,
|
||||
deltaMode: this._triggerDeltaMode,
|
||||
fromState: this._triggerFromState,
|
||||
toState: this._triggerToState,
|
||||
targetChanges: this._triggerTargetChanges,
|
||||
runtimeHours: this._triggerRuntimeHours,
|
||||
onStates: this._triggerOnStates,
|
||||
};
|
||||
}
|
||||
|
||||
private _setTypeFields(f: TriggerTypeFields): void {
|
||||
this._triggerType = f.type;
|
||||
this._triggerAttribute = f.attribute;
|
||||
this._triggerAbove = f.above;
|
||||
this._triggerBelow = f.below;
|
||||
this._triggerEquals = f.equals;
|
||||
this._triggerNotEquals = f.notEquals;
|
||||
this._triggerForMinutes = f.forMinutes;
|
||||
this._triggerTargetValue = f.targetValue;
|
||||
this._triggerDeltaMode = f.deltaMode;
|
||||
this._triggerFromState = f.fromState;
|
||||
this._triggerToState = f.toState;
|
||||
this._triggerTargetChanges = f.targetChanges;
|
||||
this._triggerRuntimeHours = f.runtimeHours;
|
||||
this._triggerOnStates = f.onStates;
|
||||
}
|
||||
|
||||
private _resetTriggerFields(): void {
|
||||
this._triggerEntityId = "";
|
||||
this._triggerEntityIds = [];
|
||||
this._triggerEntityLogic = "any";
|
||||
this._triggerAttribute = "";
|
||||
this._suggestedAttributes = [];
|
||||
this._availableAttributes = [];
|
||||
this._entityDomain = "";
|
||||
this._triggerType = "threshold";
|
||||
this._triggerAbove = "";
|
||||
this._triggerBelow = "";
|
||||
this._triggerEquals = "";
|
||||
this._triggerNotEquals = "";
|
||||
this._triggerForMinutes = "0";
|
||||
this._setTypeFields(emptyCondition());
|
||||
this._triggerCombinator = "any";
|
||||
this._triggerTargetValue = "";
|
||||
this._triggerDeltaMode = false;
|
||||
this._triggerBaselineValue = "";
|
||||
this._liveBaselineValue = null;
|
||||
this._autoCompleteOnRecovery = false;
|
||||
this._triggerFromState = "";
|
||||
this._triggerToState = "";
|
||||
this._triggerTargetChanges = "";
|
||||
this._triggerRuntimeHours = "";
|
||||
this._triggerRuntimeMaxSession = "";
|
||||
this._triggerOnStates = "";
|
||||
this._compoundLogic = "AND";
|
||||
this._compoundConditions = [];
|
||||
}
|
||||
@@ -1060,7 +1125,12 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
@change=${(e: Event) => {
|
||||
const next = { ...this._consumesParts };
|
||||
if ((e.target as HTMLInputElement).checked) next[key] = next[key] || base;
|
||||
else delete next[key];
|
||||
else {
|
||||
delete next[key];
|
||||
const text = { ...this._consumesQtyText };
|
||||
delete text[key];
|
||||
this._consumesQtyText = text;
|
||||
}
|
||||
this._consumesParts = next;
|
||||
}}
|
||||
/>
|
||||
@@ -1070,16 +1140,19 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
? html`<input
|
||||
class="consumes-qty"
|
||||
type="number"
|
||||
min="0.01"
|
||||
max="999"
|
||||
min=${CONSUMES_QTY_RANGE[0]}
|
||||
max=${CONSUMES_QTY_RANGE[1]}
|
||||
step="0.01"
|
||||
.value=${String(link.quantity)}
|
||||
.value=${this._consumesQtyText[key] ?? 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 },
|
||||
};
|
||||
// Keep the text as typed; a valid number also updates the
|
||||
// link, an empty / partial one is judged on save.
|
||||
const raw = (e.target as HTMLInputElement).value;
|
||||
this._consumesQtyText = { ...this._consumesQtyText, [key]: raw };
|
||||
const v = parseFloat(raw.replace(",", "."));
|
||||
if (Number.isFinite(v) && v >= CONSUMES_QTY_RANGE[0]) {
|
||||
this._consumesParts = { ...this._consumesParts, [key]: { ...base, quantity: v } };
|
||||
}
|
||||
}}
|
||||
/>`
|
||||
: nothing}
|
||||
@@ -1087,6 +1160,28 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
/** Parse the typed part quantities (see _consumesQtyText) into the links
|
||||
* before a save. Out of range = a message and false (nothing is sent),
|
||||
* never a silent 1. */
|
||||
private _applyConsumesQtyText(): boolean {
|
||||
const next = { ...this._consumesParts };
|
||||
for (const key of Object.keys(next)) {
|
||||
const raw = this._consumesQtyText[key];
|
||||
if (raw === undefined) continue;
|
||||
const qty = parseFloat(raw.replace(",", "."));
|
||||
if (!Number.isFinite(qty) || qty < CONSUMES_QTY_RANGE[0] || qty > CONSUMES_QTY_RANGE[1]) {
|
||||
const fmt = (n: number) => formatNumber(n, this._lang, { maximumFractionDigits: 2 });
|
||||
this._error = t("settings_value_out_of_range", this._lang)
|
||||
.replace("{min}", fmt(CONSUMES_QTY_RANGE[0]))
|
||||
.replace("{max}", fmt(CONSUMES_QTY_RANGE[1]));
|
||||
return false;
|
||||
}
|
||||
next[key] = { ...next[key], quantity: qty };
|
||||
}
|
||||
this._consumesParts = next;
|
||||
return true;
|
||||
}
|
||||
|
||||
private _toggleRequired(field: string, on: boolean): void {
|
||||
const next = new Set(this._requiredCompletion);
|
||||
if (on) next.add(field);
|
||||
@@ -1179,13 +1274,19 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
<select
|
||||
class="phase-part"
|
||||
.value=${d.partId}
|
||||
@change=${(e: Event) => this._patchPhaseDef(d.id, { partId: (e.target as HTMLSelectElement).value })}
|
||||
@change=${(e: Event) => {
|
||||
const partId = (e.target as HTMLSelectElement).value;
|
||||
this._patchPhaseDef(d.id, d.partQty ? { partId } : { partId, partQty: "1" });
|
||||
}}
|
||||
>
|
||||
<option value="">—</option>
|
||||
${this.parts.map((p) => html`<option value=${p.id} ?selected=${p.id === d.partId}>${p.name}</option>`)}
|
||||
</select>
|
||||
${d.partId ? html`
|
||||
<input class="phase-qty" type="number" min="0.01" step="0.01" .value=${d.partQty || "1"}
|
||||
<!-- The typed text as-is: a "|| '1'" here rewrote a cleared
|
||||
field to 1 and the next digit gave "13" (bug audit
|
||||
2026-09-26 #2); an empty field saves as 1. -->
|
||||
<input class="phase-qty" type="number" min=${PART_QTY_RANGE[0]} step="0.01" placeholder="1" .value=${d.partQty}
|
||||
@input=${(e: Event) => this._patchPhaseDef(d.id, { partQty: (e.target as HTMLInputElement).value })} />
|
||||
` : nothing}
|
||||
` : nothing}
|
||||
@@ -1282,253 +1383,18 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
this._error = t("calendar_entity_required", this._lang);
|
||||
return;
|
||||
}
|
||||
if (!this._applyConsumesQtyText()) return;
|
||||
this._loading = true;
|
||||
this._error = "";
|
||||
try {
|
||||
const data: Record<string, unknown> = {
|
||||
type: this._taskId
|
||||
? "maintenance_supporter/task/update"
|
||||
: "maintenance_supporter/task/create",
|
||||
entry_id: this._entryId,
|
||||
name: this._name,
|
||||
task_type: this._type,
|
||||
schedule_type: this._scheduleType,
|
||||
// `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);
|
||||
|
||||
if (this._taskId) data.task_id = this._taskId;
|
||||
|
||||
if (this._scheduleType === "one_time") {
|
||||
data.due_date = this._dueDate || null;
|
||||
data.interval_days = null;
|
||||
} else if (CALENDAR_KINDS.includes(this._scheduleType)) {
|
||||
// Calendar kinds are sent as the nested schedule; the backend prefers
|
||||
// it over the flat fields and clears any stale interval/due_date.
|
||||
data.schedule = { ...this._buildSchedule(), ...this._recurrenceExtras() };
|
||||
data.interval_days = null;
|
||||
if (this._taskId) data.due_date = null;
|
||||
} else {
|
||||
// Switching away from one-time clears the stale due_date on edit.
|
||||
if (this._taskId) data.due_date = null;
|
||||
if (this._scheduleType !== "manual" && this._intervalDays) {
|
||||
data.interval_days = parseInt(this._intervalDays, 10);
|
||||
data.interval_unit = this._intervalUnit;
|
||||
data.interval_anchor = this._intervalAnchor;
|
||||
// Season / finite-series ride a minimal nested schedule alongside the
|
||||
// flat interval fields; the backend carries them onto the interval it
|
||||
// derives from the flat fields (they can't be expressed flatly).
|
||||
// Always send an authoritative nested schedule for interval tasks so
|
||||
// an edit that clears season/ends actually clears them.
|
||||
if (this._scheduleType === "time_based") {
|
||||
data.schedule = { kind: "interval", ...this._recurrenceExtras() };
|
||||
}
|
||||
} else if (this._taskId) {
|
||||
data.interval_days = null;
|
||||
data.interval_anchor = "completion";
|
||||
}
|
||||
}
|
||||
|
||||
data.notes = this._notes || null;
|
||||
data.documentation_url = this._documentationUrl || null;
|
||||
data.custom_icon = this._customIcon || null;
|
||||
// #185: null clears the override (the update path pops the key).
|
||||
data.notify_icon = this._notifyIcon.trim() || null;
|
||||
data.priority = this._priority;
|
||||
data.labels = this._labels
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
// D#183: always sent — [] clears the mirror targets.
|
||||
data.mirror_todo_entities = this._mirrorTodoEntities.filter(Boolean);
|
||||
data.enabled = this._enabled;
|
||||
data.last_performed = this._lastPerformed || null;
|
||||
data.nfc_tag_id = this._nfcTagId || null;
|
||||
data.require_tag_scan = this._requireTagScan;
|
||||
data.allow_skip = this._allowSkip;
|
||||
data.notify_enabled = this._notifyEnabled;
|
||||
data.reading_unit = this._readingUnit.trim() || null;
|
||||
// #161 phase 2: always sent — [] clears the slots (single-value task).
|
||||
// Sent as hydrated even when the editor is hidden (type != reading), so
|
||||
// a no-op edit never wipes stored slots (the #42/#50 wipe class).
|
||||
data.readings = cleanReadingSlots(this._readings);
|
||||
// Task phases (#139): always sent — null clears a removed cycle.
|
||||
{
|
||||
const defs: Record<string, unknown> = {};
|
||||
for (const d of this._phaseDefs) {
|
||||
if (!d.name.trim()) continue;
|
||||
const def: Record<string, unknown> = { ...d.carry, name: d.name.trim() };
|
||||
const items = d.checklistText.split("\n").map((s) => s.trim()).filter(Boolean);
|
||||
if (items.length) def.checklist = items;
|
||||
const links: TaskPartLink[] = [];
|
||||
if (d.partId) {
|
||||
const qty = parseFloat(d.partQty);
|
||||
links.push({ part_id: d.partId, quantity: Number.isFinite(qty) && qty > 0 ? qty : 1 });
|
||||
}
|
||||
// entry_id travels only when present (a shared pool, #111) — an
|
||||
// own-part link stays byte-identical to what shipped before.
|
||||
for (const l of d.extraParts) {
|
||||
links.push(
|
||||
l.entry_id
|
||||
? { part_id: l.part_id, quantity: l.quantity, entry_id: l.entry_id }
|
||||
: { part_id: l.part_id, quantity: l.quantity },
|
||||
);
|
||||
}
|
||||
if (links.length) def.consumes_parts = links;
|
||||
// Override on → send the list, EMPTY included ("demands nothing");
|
||||
// off → omit the key so the task-level list falls through.
|
||||
if (d.reqOverride) def.required_completion_fields = [...d.reqFields];
|
||||
defs[d.id] = def;
|
||||
}
|
||||
const seq = this._phaseSeq.filter((id) => id in defs);
|
||||
data.phases = Object.keys(defs).length && seq.length ? defs : null;
|
||||
data.phase_sequence = data.phases ? seq : null;
|
||||
}
|
||||
// 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
|
||||
: null;
|
||||
|
||||
if (this._scheduleType === "sensor_based" && this._triggerType === "compound") {
|
||||
// Compound: a group of conditions joined by AND/OR. Each condition
|
||||
// carries its own entity + type + params; the top-level entity picker
|
||||
// does not apply here.
|
||||
const conditions = this._compoundConditions
|
||||
.map(draftToCondition)
|
||||
.filter((c): c is TriggerConfig => c !== null);
|
||||
if (conditions.length > 0) {
|
||||
const triggerConfig: TriggerConfig = {
|
||||
type: "compound",
|
||||
compound_logic: this._compoundLogic,
|
||||
conditions,
|
||||
};
|
||||
if (this._autoCompleteOnRecovery) triggerConfig.auto_complete_on_recovery = true;
|
||||
if (this._triggerCombinator === "all") triggerConfig.trigger_combinator = "all";
|
||||
data.trigger_config = triggerConfig;
|
||||
} else if (this._taskId) {
|
||||
data.trigger_config = null;
|
||||
}
|
||||
} else if (this._scheduleType === "sensor_based" && this._triggerEntityId) {
|
||||
const entityIds = this._triggerEntityIds.length > 0
|
||||
? this._triggerEntityIds
|
||||
: [this._triggerEntityId];
|
||||
const triggerConfig: TriggerConfig = {
|
||||
entity_id: entityIds[0],
|
||||
entity_ids: entityIds,
|
||||
type: this._triggerType,
|
||||
};
|
||||
if (this._triggerAttribute) triggerConfig.attribute = this._triggerAttribute;
|
||||
if (this._autoCompleteOnRecovery) triggerConfig.auto_complete_on_recovery = true;
|
||||
if (this._triggerCombinator === "all") triggerConfig.trigger_combinator = "all";
|
||||
|
||||
// Multi-entity: store entity_logic for all trigger types
|
||||
if (entityIds.length > 1) {
|
||||
triggerConfig.entity_logic = this._triggerEntityLogic;
|
||||
}
|
||||
|
||||
if (this._triggerType === "threshold") {
|
||||
if (this._triggerAbove) { const v = parseFloat(this._triggerAbove); if (!isNaN(v)) triggerConfig.trigger_above = v; }
|
||||
if (this._triggerBelow) { const v = parseFloat(this._triggerBelow); if (!isNaN(v)) triggerConfig.trigger_below = v; }
|
||||
if (this._triggerEquals) { const v = parseFloat(this._triggerEquals); if (!isNaN(v)) triggerConfig.trigger_equals = v; }
|
||||
if (this._triggerNotEquals) { const v = parseFloat(this._triggerNotEquals); if (!isNaN(v)) triggerConfig.trigger_not_equals = v; }
|
||||
if (this._triggerForMinutes) { const v = parseInt(this._triggerForMinutes, 10); if (!isNaN(v)) triggerConfig.trigger_for_minutes = v; }
|
||||
} else if (this._triggerType === "counter") {
|
||||
if (this._triggerTargetValue) { const v = parseFloat(this._triggerTargetValue); if (!isNaN(v)) triggerConfig.trigger_target_value = v; }
|
||||
triggerConfig.trigger_delta_mode = this._triggerDeltaMode;
|
||||
// #102: optional counting start value ("last service was at X").
|
||||
// Empty = count from the reading at creation / keep the live
|
||||
// baseline; the backend clears stale Store state when it changes.
|
||||
if (this._triggerDeltaMode && this._triggerBaselineValue) {
|
||||
const b = parseFloat(this._triggerBaselineValue);
|
||||
if (!isNaN(b) && b >= 0) triggerConfig.trigger_baseline_value = b;
|
||||
}
|
||||
} else if (this._triggerType === "state_change") {
|
||||
if (this._triggerFromState) triggerConfig.trigger_from_state = this._triggerFromState;
|
||||
if (this._triggerToState) triggerConfig.trigger_to_state = this._triggerToState;
|
||||
if (this._triggerTargetChanges) { const v = parseInt(this._triggerTargetChanges, 10); if (!isNaN(v)) triggerConfig.trigger_target_changes = v; }
|
||||
// #136: the new state must hold this long before a change counts.
|
||||
if (this._triggerForMinutes) { const v = parseInt(this._triggerForMinutes, 10); if (!isNaN(v)) triggerConfig.trigger_for_minutes = v; }
|
||||
} else if (this._triggerType === "runtime") {
|
||||
if (this._triggerRuntimeHours) { const v = parseFloat(this._triggerRuntimeHours); if (!isNaN(v)) triggerConfig.trigger_runtime_hours = v; }
|
||||
// #149: per-session cap (seconds) against sensors stuck ON.
|
||||
if (this._triggerRuntimeMaxSession) { const v = parseInt(this._triggerRuntimeMaxSession, 10); if (!isNaN(v) && v > 0) triggerConfig.trigger_runtime_max_session_seconds = v; }
|
||||
const onStates = this._triggerOnStates.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
if (onStates.length > 0) triggerConfig.trigger_on_states = onStates;
|
||||
}
|
||||
|
||||
data.trigger_config = triggerConfig;
|
||||
} else if (this._taskId) {
|
||||
data.trigger_config = null;
|
||||
}
|
||||
|
||||
// Schedule time only sent when feature is enabled — null clears.
|
||||
// Every date-driven kind takes one (#168), not only the interval; an
|
||||
// unticked checkbox (or a ticked one with an empty picker) clears it.
|
||||
if (this.scheduleTimeEnabled && SCHEDULE_TIME_KINDS.includes(this._scheduleType)) {
|
||||
const t = this._scheduleTimeOn ? this._scheduleTime.trim() : "";
|
||||
data.schedule_time = /^([01]\d|2[0-3]):[0-5]\d$/.test(t) ? t : null;
|
||||
}
|
||||
|
||||
if (this.checklistsEnabled) {
|
||||
const items = this._checklistText
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 100);
|
||||
data.checklist = items.length ? items : null;
|
||||
}
|
||||
|
||||
// v1.3.0: on_complete_action + quick_complete_defaults (gated)
|
||||
if (this.completionActionsEnabled) {
|
||||
const svc = this._actionService.trim();
|
||||
if (svc && /^[a-z][a-z0-9_]*\.[a-z0-9_]+$/.test(svc)) {
|
||||
const action: Record<string, unknown> = { service: svc };
|
||||
const tgt = this._actionTargetEntity.trim();
|
||||
if (tgt) action.target = { entity_id: tgt };
|
||||
const dataDict = this._buildActionData();
|
||||
if (Object.keys(dataDict).length > 0) {
|
||||
action.data = dataDict;
|
||||
}
|
||||
data.on_complete_action = action;
|
||||
} else {
|
||||
data.on_complete_action = null;
|
||||
}
|
||||
|
||||
const qcd: Record<string, unknown> = {};
|
||||
if (this._qcNotes.trim()) qcd.notes = this._qcNotes.trim();
|
||||
const cost = parseFloat(this._qcCost);
|
||||
if (!isNaN(cost) && cost >= 0) qcd.cost = cost;
|
||||
const dur = parseInt(this._qcDuration, 10);
|
||||
if (!isNaN(dur) && dur >= 0) qcd.duration = dur;
|
||||
if (this._qcFeedback) qcd.feedback = this._qcFeedback;
|
||||
data.quick_complete_defaults = Object.keys(qcd).length ? qcd : null;
|
||||
}
|
||||
|
||||
const result = await this.hass.connection.sendMessagePromise(data) as { task_id?: string };
|
||||
// helpers/ws-run runWs: a refusal (or a payload that failed to build)
|
||||
// lands on the error line as the server's reason / "Save failed".
|
||||
const result = await runWs<{ task_id?: string }>(
|
||||
this,
|
||||
() => this.hass.connection.sendMessagePromise<{ task_id?: string }>(this._savePayload()),
|
||||
{ fallbackKey: "save_error", onError: (m) => { this._error = m; } },
|
||||
);
|
||||
if (result === undefined) return;
|
||||
const savedTaskId = this._taskId || result?.task_id;
|
||||
|
||||
// Environmental entity lives in adaptive_config (Store-managed),
|
||||
@@ -1573,7 +1439,7 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
entry_id: this._entryId,
|
||||
task_id: savedTaskId,
|
||||
enabled: this._adaptiveEnabled,
|
||||
...(alpha >= 0.1 && alpha <= 0.9 ? { ewa_alpha: alpha } : {}),
|
||||
...(alpha >= EWA_ALPHA_RANGE[0] && alpha <= EWA_ALPHA_RANGE[1] ? { ewa_alpha: alpha } : {}),
|
||||
...(!isNaN(minIv) && minIv >= 1 ? { min_interval_days: minIv } : {}),
|
||||
...(!isNaN(maxIv) && maxIv >= 1 ? { max_interval_days: maxIv } : {}),
|
||||
seasonal_enabled: this._adaptiveSeasonal,
|
||||
@@ -1590,13 +1456,247 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
this._open = false;
|
||||
}
|
||||
this.dispatchEvent(new CustomEvent("task-saved"));
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang, t("save_error", this._lang));
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** The task/create | task/update message from the form (validated above). */
|
||||
private _savePayload(): Record<string, unknown> {
|
||||
const data: Record<string, unknown> = {
|
||||
type: this._taskId
|
||||
? "maintenance_supporter/task/update"
|
||||
: "maintenance_supporter/task/create",
|
||||
entry_id: this._entryId,
|
||||
name: this._name,
|
||||
task_type: this._type,
|
||||
schedule_type: this._scheduleType,
|
||||
// `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);
|
||||
|
||||
if (this._taskId) data.task_id = this._taskId;
|
||||
|
||||
if (this._scheduleType === "one_time") {
|
||||
data.due_date = this._dueDate || null;
|
||||
data.interval_days = null;
|
||||
} else if (CALENDAR_KINDS.includes(this._scheduleType)) {
|
||||
// Calendar kinds are sent as the nested schedule; the backend prefers
|
||||
// it over the flat fields and clears any stale interval/due_date.
|
||||
data.schedule = { ...this._buildSchedule(), ...this._recurrenceExtras() };
|
||||
data.interval_days = null;
|
||||
if (this._taskId) data.due_date = null;
|
||||
} else {
|
||||
// Switching away from one-time clears the stale due_date on edit.
|
||||
if (this._taskId) data.due_date = null;
|
||||
if (this._scheduleType !== "manual" && this._intervalDays) {
|
||||
data.interval_days = parseInt(this._intervalDays, 10);
|
||||
data.interval_unit = this._intervalUnit;
|
||||
data.interval_anchor = this._intervalAnchor;
|
||||
// Season / finite-series ride a minimal nested schedule alongside the
|
||||
// flat interval fields; the backend carries them onto the interval it
|
||||
// derives from the flat fields (they can't be expressed flatly).
|
||||
// Always send an authoritative nested schedule for interval tasks so
|
||||
// an edit that clears season/ends actually clears them.
|
||||
if (this._scheduleType === "time_based") {
|
||||
data.schedule = { kind: "interval", ...this._recurrenceExtras() };
|
||||
}
|
||||
} else if (this._taskId) {
|
||||
data.interval_days = null;
|
||||
data.interval_anchor = "completion";
|
||||
}
|
||||
}
|
||||
|
||||
data.notes = this._notes || null;
|
||||
data.documentation_url = this._documentationUrl || null;
|
||||
data.custom_icon = this._customIcon || null;
|
||||
// #185: null clears the override (the update path pops the key).
|
||||
data.notify_icon = this._notifyIcon.trim() || null;
|
||||
data.priority = this._priority;
|
||||
data.labels = this._labels
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
// D#183: always sent — [] clears the mirror targets.
|
||||
data.mirror_todo_entities = this._mirrorTodoEntities.filter(Boolean);
|
||||
data.enabled = this._enabled;
|
||||
if (!this._taskId || this._lastPerformed !== this._loadedLastPerformed) {
|
||||
data.last_performed = this._lastPerformed || null;
|
||||
}
|
||||
data.nfc_tag_id = this._nfcTagId || null;
|
||||
data.require_tag_scan = this._requireTagScan;
|
||||
data.allow_skip = this._allowSkip;
|
||||
data.notify_enabled = this._notifyEnabled;
|
||||
data.reading_unit = this._readingUnit.trim() || null;
|
||||
// #161 phase 2: always sent — [] clears the slots (single-value task).
|
||||
// Sent as hydrated even when the editor is hidden (type != reading), so
|
||||
// a no-op edit never wipes stored slots (the #42/#50 wipe class).
|
||||
data.readings = cleanReadingSlots(this._readings);
|
||||
// Task phases (#139): always sent — null clears a removed cycle.
|
||||
{
|
||||
const defs: Record<string, unknown> = {};
|
||||
for (const d of this._phaseDefs) {
|
||||
if (!d.name.trim()) continue;
|
||||
const def: Record<string, unknown> = { ...d.carry, name: d.name.trim() };
|
||||
const items = d.checklistText.split("\n").map((s) => s.trim()).filter(Boolean);
|
||||
if (items.length) def.checklist = items;
|
||||
const links: TaskPartLink[] = [];
|
||||
if (d.partId) {
|
||||
const qty = parseFloat(d.partQty);
|
||||
links.push({ part_id: d.partId, quantity: Number.isFinite(qty) && qty > 0 ? qty : 1 });
|
||||
}
|
||||
// entry_id travels only when present (a shared pool, #111) — an
|
||||
// own-part link stays byte-identical to what shipped before.
|
||||
for (const l of d.extraParts) {
|
||||
links.push(
|
||||
l.entry_id
|
||||
? { part_id: l.part_id, quantity: l.quantity, entry_id: l.entry_id }
|
||||
: { part_id: l.part_id, quantity: l.quantity },
|
||||
);
|
||||
}
|
||||
if (links.length) def.consumes_parts = links;
|
||||
// Override on → send the list, EMPTY included ("demands nothing");
|
||||
// off → omit the key so the task-level list falls through.
|
||||
if (d.reqOverride) def.required_completion_fields = [...d.reqFields];
|
||||
defs[d.id] = def;
|
||||
}
|
||||
const seq = this._phaseSeq.filter((id) => id in defs);
|
||||
data.phases = Object.keys(defs).length && seq.length ? defs : null;
|
||||
data.phase_sequence = data.phases ? seq : null;
|
||||
}
|
||||
// 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 },
|
||||
);
|
||||
}
|
||||
if (!this._taskId || this._responsibleUserId !== this._loadedResponsibleUserId) {
|
||||
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
|
||||
: null;
|
||||
|
||||
if (this._scheduleType === "sensor_based" && this._triggerType === "compound") {
|
||||
// Compound: a group of conditions joined by AND/OR. Each condition
|
||||
// carries its own entity + type + params; the top-level entity picker
|
||||
// does not apply here.
|
||||
const conditions = this._compoundConditions
|
||||
.map(draftToCondition)
|
||||
.filter((c): c is TriggerConfig => c !== null);
|
||||
if (conditions.length > 0) {
|
||||
const triggerConfig: TriggerConfig = {
|
||||
type: "compound",
|
||||
compound_logic: this._compoundLogic,
|
||||
conditions,
|
||||
};
|
||||
if (this._autoCompleteOnRecovery) triggerConfig.auto_complete_on_recovery = true;
|
||||
if (this._triggerCombinator === "all") triggerConfig.trigger_combinator = "all";
|
||||
data.trigger_config = triggerConfig;
|
||||
} else if (this._taskId) {
|
||||
data.trigger_config = null;
|
||||
}
|
||||
} else if (this._scheduleType === "sensor_based" && this._triggerEntityId) {
|
||||
const entityIds = this._triggerEntityIds.length > 0
|
||||
? this._triggerEntityIds
|
||||
: [this._triggerEntityId];
|
||||
const triggerConfig: TriggerConfig = {
|
||||
entity_id: entityIds[0],
|
||||
entity_ids: entityIds,
|
||||
type: this._triggerType,
|
||||
};
|
||||
// The per-type fields: the same rules a compound condition uses.
|
||||
applyTypeFields(triggerConfig, this._typeFields());
|
||||
if (this._autoCompleteOnRecovery) triggerConfig.auto_complete_on_recovery = true;
|
||||
if (this._triggerCombinator === "all") triggerConfig.trigger_combinator = "all";
|
||||
|
||||
// Multi-entity: store entity_logic for all trigger types
|
||||
if (entityIds.length > 1) {
|
||||
triggerConfig.entity_logic = this._triggerEntityLogic;
|
||||
}
|
||||
|
||||
// Flat-form-only fields.
|
||||
if (this._triggerType === "counter" && this._triggerDeltaMode && this._triggerBaselineValue) {
|
||||
// #102: optional counting start value ("last service was at X").
|
||||
// Empty = count from the reading at creation / keep the live
|
||||
// baseline; the backend clears stale Store state when it changes.
|
||||
const b = parseFloat(this._triggerBaselineValue);
|
||||
if (!isNaN(b) && b >= 0) triggerConfig.trigger_baseline_value = b;
|
||||
} else if (this._triggerType === "runtime" && this._triggerRuntimeMaxSession) {
|
||||
// #149: per-session cap (seconds) against sensors stuck ON.
|
||||
const v = parseInt(this._triggerRuntimeMaxSession, 10);
|
||||
if (!isNaN(v) && v > 0) triggerConfig.trigger_runtime_max_session_seconds = v;
|
||||
}
|
||||
|
||||
data.trigger_config = triggerConfig;
|
||||
} else if (this._taskId) {
|
||||
data.trigger_config = null;
|
||||
}
|
||||
|
||||
// Schedule time only sent when feature is enabled — null clears.
|
||||
// Every date-driven kind takes one (#168), not only the interval; an
|
||||
// unticked checkbox (or a ticked one with an empty picker) clears it.
|
||||
if (this.scheduleTimeEnabled && SCHEDULE_TIME_KINDS.includes(this._scheduleType)) {
|
||||
const t = this._scheduleTimeOn ? this._scheduleTime.trim() : "";
|
||||
data.schedule_time = /^([01]\d|2[0-3]):[0-5]\d$/.test(t) ? t : null;
|
||||
}
|
||||
|
||||
if (this.checklistsEnabled) {
|
||||
const items = this._checklistText
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 100);
|
||||
data.checklist = items.length ? items : null;
|
||||
}
|
||||
|
||||
// v1.3.0: on_complete_action + quick_complete_defaults (gated)
|
||||
if (this.completionActionsEnabled) {
|
||||
const svc = this._actionService.trim();
|
||||
if (svc && /^[a-z][a-z0-9_]*\.[a-z0-9_]+$/.test(svc)) {
|
||||
const action: Record<string, unknown> = { service: svc };
|
||||
const tgt = this._actionTargetEntity.trim();
|
||||
if (tgt) action.target = { entity_id: tgt };
|
||||
const dataDict = this._buildActionData();
|
||||
if (Object.keys(dataDict).length > 0) {
|
||||
action.data = dataDict;
|
||||
}
|
||||
data.on_complete_action = action;
|
||||
} else {
|
||||
data.on_complete_action = null;
|
||||
}
|
||||
|
||||
const qcd: Record<string, unknown> = {};
|
||||
if (this._qcNotes.trim()) qcd.notes = this._qcNotes.trim();
|
||||
const cost = parseFloat(this._qcCost);
|
||||
if (!isNaN(cost) && cost >= 0) qcd.cost = cost;
|
||||
const dur = parseDurationMinutes(this._qcDuration);
|
||||
if (dur !== null) qcd.duration = dur;
|
||||
if (this._qcFeedback) qcd.feedback = this._qcFeedback;
|
||||
data.quick_complete_defaults = Object.keys(qcd).length ? qcd : null;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
private _close(): void {
|
||||
this._open = false;
|
||||
this._warning = "";
|
||||
@@ -1899,8 +1999,8 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
<ms-textfield
|
||||
label="${t("adaptive_ewa_alpha", L)}"
|
||||
type="number"
|
||||
min="0.1"
|
||||
max="0.9"
|
||||
min=${EWA_ALPHA_RANGE[0]}
|
||||
max=${EWA_ALPHA_RANGE[1]}
|
||||
step="0.1"
|
||||
.value=${this._adaptiveAlpha}
|
||||
@input=${(e: Event) => (this._adaptiveAlpha = (e.target as HTMLInputElement).value)}
|
||||
@@ -2041,6 +2141,8 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
})}
|
||||
<ms-textfield label="${t("target_changes", L)}" type="number" .value=${c.targetChanges}
|
||||
@input=${(e: Event) => this._patchCondition(i, { targetChanges: (e.target as HTMLInputElement).value })}></ms-textfield>
|
||||
<ms-textfield label="${t("for_minutes", L)}" type="number" .value=${c.forMinutes}
|
||||
@input=${(e: Event) => this._patchCondition(i, { forMinutes: (e.target as HTMLInputElement).value })}></ms-textfield>
|
||||
`;
|
||||
}
|
||||
if (c.type === "runtime") {
|
||||
@@ -2237,7 +2339,7 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
private _buildSchedule(): Record<string, unknown> {
|
||||
const withOffset = (schedule: Record<string, unknown>) => {
|
||||
const off = parseInt(this._calOffset, 10) || 0;
|
||||
if (off) schedule.offset = Math.max(-15, Math.min(off, 15));
|
||||
if (off) schedule.offset = Math.max(-SCHEDULE_OFFSET_MAX_DAYS, Math.min(off, SCHEDULE_OFFSET_MAX_DAYS));
|
||||
return schedule;
|
||||
};
|
||||
if (this._scheduleType === "weekdays") {
|
||||
@@ -2429,8 +2531,8 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
label="${t("recurrence_offset", L)}"
|
||||
helper="${t("recurrence_offset_help", L)}"
|
||||
type="number"
|
||||
min="-15"
|
||||
max="15"
|
||||
min=${-SCHEDULE_OFFSET_MAX_DAYS}
|
||||
max=${SCHEDULE_OFFSET_MAX_DAYS}
|
||||
.value=${this._calOffset}
|
||||
@input=${(e: Event) => (this._calOffset = (e.target as HTMLInputElement).value)}
|
||||
></ms-textfield>`;
|
||||
@@ -2623,7 +2725,7 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
<ms-textfield
|
||||
label="${t("target_changes", L)}"
|
||||
type="number"
|
||||
min="1"
|
||||
min=${TRIGGER_TARGET_CHANGES_RANGE[0]}
|
||||
.value=${this._triggerTargetChanges}
|
||||
@input=${(e: Event) => (this._triggerTargetChanges = (e.target as HTMLInputElement).value)}
|
||||
></ms-textfield>
|
||||
@@ -2634,7 +2736,7 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
<ms-textfield
|
||||
label="${t("for_at_least_minutes", L)}"
|
||||
type="number"
|
||||
min="0"
|
||||
min=${TRIGGER_FOR_MINUTES_RANGE[0]}
|
||||
.value=${this._triggerForMinutes}
|
||||
@input=${(e: Event) => (this._triggerForMinutes = (e.target as HTMLInputElement).value)}
|
||||
></ms-textfield>
|
||||
@@ -2687,6 +2789,7 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
@change=${(e: Event) => {
|
||||
this._entryId = (e.target as HTMLSelectElement).value;
|
||||
this._consumesParts = {};
|
||||
this._consumesQtyText = {};
|
||||
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).
|
||||
@@ -2872,8 +2975,8 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
<ms-textfield
|
||||
label="${t("warning_days", L)}"
|
||||
type="number"
|
||||
min="0"
|
||||
max="365"
|
||||
min=${WARNING_DAYS_RANGE[0]}
|
||||
max=${WARNING_DAYS_RANGE[1]}
|
||||
.value=${this._warningDays}
|
||||
@input=${(e: Event) => (this._warningDays = (e.target as HTMLInputElement).value)}
|
||||
></ms-textfield>
|
||||
|
||||
@@ -12,12 +12,12 @@
|
||||
import { LitElement, html, css, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { t, ensureLocale, langOf } from "../styles";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { runWs } from "../helpers/ws-run";
|
||||
import { downloadUrl } from "../helpers/download";
|
||||
import { downloadSignedDocument, openSignedDocument } from "../helpers/document-url";
|
||||
import { isSafeHttpUrl } from "../helpers/url";
|
||||
import { formatBytes } from "../helpers/format-bytes";
|
||||
import { docDisplayName, CATEGORIES, CATEGORY_ICONS } from "../helpers/document-categories";
|
||||
import { docCategory, docDisplayName, CATEGORY_ICONS } from "../helpers/document-categories";
|
||||
import { DOC_FILTER_MIN, filterDocuments } from "../helpers/document-filter";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
@@ -82,18 +82,31 @@ export class MaintenanceTaskDocuments extends LitElement {
|
||||
}
|
||||
|
||||
private async _load(): Promise<void> {
|
||||
try {
|
||||
const r = await this.hass.connection.sendMessagePromise<{ documents: Doc[] }>({
|
||||
type: "maintenance_supporter/documents/list",
|
||||
entry_id: this.entryId,
|
||||
});
|
||||
this._docs = r.documents || [];
|
||||
this._loaded = true;
|
||||
this._error = "";
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
this._loaded = true;
|
||||
}
|
||||
const r = await runWs<{ documents: Doc[] }>(
|
||||
this,
|
||||
{ type: "maintenance_supporter/documents/list", entry_id: this.entryId },
|
||||
{ onError: (m) => { this._error = m; } },
|
||||
);
|
||||
this._loaded = true;
|
||||
if (r === undefined) return;
|
||||
this._docs = r?.documents || [];
|
||||
this._error = "";
|
||||
}
|
||||
|
||||
/** A document write — helpers/ws-run runWs with the busy flag, the error
|
||||
* line and the list reload. */
|
||||
private async _update(payload: Record<string, unknown>): Promise<void> {
|
||||
this._error = "";
|
||||
await runWs(this, { type: "maintenance_supporter/documents/update", ...payload }, {
|
||||
busy: (b) => { this._busy = b; },
|
||||
reload: () => this._load(),
|
||||
onError: (m) => { this._error = m; },
|
||||
});
|
||||
}
|
||||
|
||||
/** The signed open / download helpers, their failure on the error line. */
|
||||
private async _signed(call: () => Promise<unknown>): Promise<void> {
|
||||
await runWs(this, call, { onError: (m) => { this._error = m; } });
|
||||
}
|
||||
|
||||
private _links(doc: Doc): string[] {
|
||||
@@ -109,20 +122,7 @@ export class MaintenanceTaskDocuments extends LitElement {
|
||||
}
|
||||
|
||||
private async _setLinks(doc: Doc, ids: string[]): Promise<void> {
|
||||
this._busy = true;
|
||||
this._error = "";
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/documents/update",
|
||||
doc_id: doc.id,
|
||||
[this._linkField]: ids,
|
||||
});
|
||||
await this._load();
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._busy = false;
|
||||
}
|
||||
await this._update({ doc_id: doc.id, [this._linkField]: ids });
|
||||
}
|
||||
|
||||
private _link(): void {
|
||||
@@ -158,38 +158,17 @@ export class MaintenanceTaskDocuments extends LitElement {
|
||||
// A per-task page hint jumps straight to the relevant page via the PDF
|
||||
// viewer's #page=N fragment (client-side, so it never breaks the signature).
|
||||
const page = this._pageFor(doc);
|
||||
try {
|
||||
await openSignedDocument(this.hass, doc.id, page ? `#page=${page}` : "");
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
}
|
||||
await this._signed(() => openSignedDocument(this.hass, doc.id, page ? `#page=${page}` : ""));
|
||||
}
|
||||
|
||||
/** Set (page >= 1) or clear (0) the jump-to page for this doc's task link. */
|
||||
private async _setPage(doc: Doc, page: number): Promise<void> {
|
||||
if (!this.taskId) return;
|
||||
this._busy = true;
|
||||
this._error = "";
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/documents/update",
|
||||
doc_id: doc.id,
|
||||
task_pages: { [this.taskId]: page },
|
||||
});
|
||||
await this._load();
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._busy = false;
|
||||
}
|
||||
await this._update({ doc_id: doc.id, task_pages: { [this.taskId]: page } });
|
||||
}
|
||||
|
||||
private async _download(doc: Doc): Promise<void> {
|
||||
try {
|
||||
await downloadSignedDocument(this.hass, doc.id, doc.filename || doc.title || "document");
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
}
|
||||
await this._signed(() => downloadSignedDocument(this.hass, doc.id, doc.filename || doc.title || "document"));
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -242,7 +221,7 @@ export class MaintenanceTaskDocuments extends LitElement {
|
||||
const isFile = doc.kind === "file";
|
||||
const isPdf = this._isPdf(doc);
|
||||
const page = this._pageFor(doc);
|
||||
const cat = (doc.tags || []).find((x) => (CATEGORIES as readonly string[]).includes(x)) || "other";
|
||||
const cat = docCategory(doc);
|
||||
const meta = isFile ? formatBytes(doc.size, L) : t("doc_link_badge", L);
|
||||
return html`
|
||||
<div class="tdoc-row">
|
||||
|
||||
+163
-157
@@ -14,15 +14,21 @@
|
||||
|
||||
import { LitElement, html, css, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { sharedStyles, t, STATUS_COLORS, formatDate, formatInterval, formatRecurrence, formatCost, formatDuration, currencySymbolOf, langOf, syncCurrencyDecimals} from "../styles";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { sharedStyles, t, formatDate, formatInterval, formatRecurrence, formatCost, formatDuration, currencySymbolOf, langOf, syncCurrencyDecimals} from "../styles";
|
||||
import { runWs } from "../helpers/ws-run";
|
||||
import { focusModalShell, modalShellStyles, renderModalShell } from "../helpers/modal-shell";
|
||||
import { isoDateLocal } from "../helpers/calendar-bucket";
|
||||
import { buildCompleteDialogArgs } from "../helpers/complete-dialog-args";
|
||||
import { phaseLabel } from "../helpers/phases";
|
||||
import { buildHistoryEntryDraft } from "../helpers/history-draft";
|
||||
import { readingSlotDelta } from "../helpers/reading-slots";
|
||||
import { taskRef } from "../helpers/reference";
|
||||
import { renderHistoryEntry } from "../renderers/history";
|
||||
import { newestFirst, renderHistoryEntry } from "../renderers/history";
|
||||
import { renderStatusBadge, statusColor } from "../renderers/status";
|
||||
import { fetchSettingsOnce } from "../helpers/settings-cache";
|
||||
import { canWrite, NO_DELEGATION, type WriteAccess } from "../helpers/permissions";
|
||||
import { confirmAction } from "../helpers/confirm";
|
||||
import { ToastTimer } from "../helpers/toast";
|
||||
import { renderWeibullSection } from "../renderers/weibull";
|
||||
import { renderPredictionSection } from "../renderers/prediction";
|
||||
import { renderRecommendationBars } from "../renderers/recommendation";
|
||||
@@ -69,7 +75,10 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
|
||||
completion_actions: false,
|
||||
};
|
||||
@state() private _toast = "";
|
||||
private _featuresLoaded = false;
|
||||
private readonly _toastTimer = new ToastTimer();
|
||||
/** Operator-write delegation (helpers/permissions): Edit / Archive /
|
||||
* Delete follow canWrite() like the panel, not `is_admin` alone. */
|
||||
@state() private _access: WriteAccess = NO_DELEGATION;
|
||||
/** Currency symbol for the complete dialog's cost suggestion — read off
|
||||
* the same settings response as the feature flags. */
|
||||
private _currencySymbol = "";
|
||||
@@ -81,6 +90,9 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
|
||||
/** Open the dialog. Loads fresh data from /object via WS so dialog stays in
|
||||
* sync even if the underlying card has stale data. */
|
||||
public async openFor(entryId: string, taskId: string): Promise<void> {
|
||||
// Reopened for ANOTHER task while open: never show the previous one
|
||||
// under the new ids until the fresh load lands.
|
||||
if (entryId !== this._entryId || taskId !== this._taskId) this._task = null;
|
||||
this._entryId = entryId;
|
||||
this._taskId = taskId;
|
||||
this._error = "";
|
||||
@@ -97,60 +109,70 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
|
||||
|
||||
/** Pull the active feature flags so adaptive sections only render when
|
||||
* Adaptive / Seasonal / Environmental are actually enabled (matches the
|
||||
* panel's behaviour). Cached after first load. */
|
||||
* panel's behaviour), plus the write delegation and currency. Through
|
||||
* the page-wide settings cache (one fetch per page, invalidated by
|
||||
* global/update) — the private fetch here bypassed it and never
|
||||
* re-read after a settings change (DRY audit 2026-09-26). A failed
|
||||
* fetch serves the all-off fallback and asks again next open. */
|
||||
private async _loadFeatures(): Promise<void> {
|
||||
if (this._featuresLoaded) return;
|
||||
try {
|
||||
const r = await this.hass.connection.sendMessagePromise<{
|
||||
features?: Partial<AdvancedFeatures>;
|
||||
budget?: { currency_symbol?: string; currency_decimals?: number };
|
||||
}>({ type: "maintenance_supporter/settings" });
|
||||
if (r?.features) {
|
||||
this._features = { ...this._features, ...r.features };
|
||||
}
|
||||
this._currencySymbol = currencySymbolOf(r?.budget);
|
||||
syncCurrencyDecimals(r?.budget);
|
||||
this._featuresLoaded = true;
|
||||
} catch {
|
||||
// Settings endpoint unavailable — leave defaults (all false), the
|
||||
// adaptive panel will then stay hidden.
|
||||
}
|
||||
const settings = await fetchSettingsOnce(this.hass);
|
||||
this._features = { ...this._features, ...settings.features };
|
||||
this._access = settings.access;
|
||||
this._currencySymbol = currencySymbolOf(settings.budget ?? undefined);
|
||||
syncCurrencyDecimals(settings.budget ?? undefined);
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
this._loadSeq++; // a load still in flight belongs to the closed dialog
|
||||
this._open = false;
|
||||
this._task = null;
|
||||
this._error = "";
|
||||
this._toastTimer.clear();
|
||||
this._toast = "";
|
||||
}
|
||||
|
||||
private _showToast(msg: string, ms?: number): void {
|
||||
this._toast = msg;
|
||||
this._toastTimer.schedule(() => { this._toast = ""; }, ms);
|
||||
}
|
||||
|
||||
/** Only the newest `_loadTask` may land: the answer for a task the dialog
|
||||
* was opened for BEFORE (or after it closed) looked up the CURRENT task
|
||||
* id in the wrong object, found nothing, and left the dialog stuck on
|
||||
* "Loading" (bug audit 2026-09-26 #2). */
|
||||
private _loadSeq = 0;
|
||||
|
||||
private async _loadTask(): Promise<void> {
|
||||
if (!this._entryId || !this._taskId) return;
|
||||
try {
|
||||
const r = await this.hass.connection.sendMessagePromise<MaintenanceObjectFull>({
|
||||
type: "maintenance_supporter/object",
|
||||
entry_id: this._entryId,
|
||||
});
|
||||
this._objectName = r.object?.name || "";
|
||||
const found = (r.tasks || []).find((t) => t.id === this._taskId);
|
||||
this._task = found ?? null;
|
||||
this._taskRef = taskRef(r.object, found);
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
const entryId = this._entryId;
|
||||
const taskId = this._taskId;
|
||||
if (!entryId || !taskId) return;
|
||||
const seq = ++this._loadSeq;
|
||||
let failure = "";
|
||||
const r = await runWs<MaintenanceObjectFull>(
|
||||
this,
|
||||
{ type: "maintenance_supporter/object", entry_id: entryId },
|
||||
{ onError: (m) => { failure = m; } },
|
||||
);
|
||||
if (seq !== this._loadSeq) return;
|
||||
if (r === undefined) {
|
||||
this._error = failure;
|
||||
return;
|
||||
}
|
||||
this._objectName = r?.object?.name || "";
|
||||
const found = (r?.tasks || []).find((t) => t.id === taskId);
|
||||
this._task = found ?? null;
|
||||
this._taskRef = taskRef(r?.object, found);
|
||||
// A task deleted meanwhile: say so instead of "Loading" forever.
|
||||
if (!found) this._error = t("ws_err_not_found", this._lang);
|
||||
}
|
||||
|
||||
private async _runWs(payload: Record<string, unknown>): Promise<boolean> {
|
||||
this._busy = true;
|
||||
/** helpers/ws-run runWs bound to this dialog's busy flag and error line. */
|
||||
private _runWs<T = unknown>(payload: Record<string, unknown>): Promise<T | undefined> {
|
||||
this._error = "";
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise(payload);
|
||||
this._busy = false;
|
||||
return true;
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
this._busy = false;
|
||||
return false;
|
||||
}
|
||||
return runWs<T>(this, payload, {
|
||||
busy: (b) => { this._busy = b; },
|
||||
onError: (m) => { this._error = m; },
|
||||
});
|
||||
}
|
||||
|
||||
private _notifyChanged(action: string): void {
|
||||
@@ -195,8 +217,9 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
|
||||
task,
|
||||
objects,
|
||||
lang: this._lang,
|
||||
checklist: task.checklist || [],
|
||||
adaptiveEnabled: !!task.adaptive_config?.enabled,
|
||||
// The feature switches gate the checklist and the adaptive
|
||||
// feedback exactly like the panel (DRY audit 2026-09-26).
|
||||
features: this._features,
|
||||
currencySymbol: this._currencySymbol,
|
||||
}),
|
||||
);
|
||||
@@ -215,7 +238,7 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
|
||||
task_id: this._taskId,
|
||||
reason: this._skipReason.trim() || null,
|
||||
});
|
||||
if (ok) {
|
||||
if (ok !== undefined) {
|
||||
this._notifyChanged("skip");
|
||||
this.close();
|
||||
}
|
||||
@@ -229,7 +252,7 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
|
||||
task_id: this._taskId,
|
||||
date: this._resetDate || undefined,
|
||||
});
|
||||
if (ok) {
|
||||
if (ok !== undefined) {
|
||||
this._notifyChanged("reset");
|
||||
this.close();
|
||||
}
|
||||
@@ -258,15 +281,19 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
|
||||
|
||||
private async _onDelete(): Promise<void> {
|
||||
if (!this._entryId || !this._taskId) return;
|
||||
const confirmText = t("delete_task_confirm", this._lang)
|
||||
|| `Delete "${this._task?.name}"?`;
|
||||
if (!window.confirm(confirmText)) return;
|
||||
const confirmed = await confirmAction(this.hass, {
|
||||
title: t("delete", this._lang),
|
||||
message: t("delete_task_confirm", this._lang),
|
||||
confirmText: t("delete", this._lang),
|
||||
danger: true,
|
||||
});
|
||||
if (!confirmed) return;
|
||||
const ok = await this._runWs({
|
||||
type: "maintenance_supporter/task/delete",
|
||||
entry_id: this._entryId,
|
||||
task_id: this._taskId,
|
||||
});
|
||||
if (ok) {
|
||||
if (ok !== undefined) {
|
||||
this._notifyChanged("delete");
|
||||
this.close();
|
||||
}
|
||||
@@ -279,7 +306,7 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
|
||||
entry_id: this._entryId,
|
||||
task_id: this._taskId,
|
||||
});
|
||||
if (ok) {
|
||||
if (ok !== undefined) {
|
||||
this._notifyChanged("archive");
|
||||
this.close();
|
||||
}
|
||||
@@ -292,7 +319,7 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
|
||||
entry_id: this._entryId,
|
||||
task_id: this._taskId,
|
||||
});
|
||||
if (ok) {
|
||||
if (ok !== undefined) {
|
||||
this._notifyChanged("unarchive");
|
||||
this.close();
|
||||
}
|
||||
@@ -315,41 +342,32 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
|
||||
task_id: this._taskId,
|
||||
interval: this._task.suggested_interval,
|
||||
});
|
||||
if (ok) {
|
||||
this._toast = t("suggestion_applied", this._lang);
|
||||
if (ok !== undefined) {
|
||||
this._showToast(t("suggestion_applied", this._lang));
|
||||
this._notifyChanged("apply_suggestion");
|
||||
// Refresh local task so the recommendation card hides
|
||||
await this._loadTask();
|
||||
setTimeout(() => { this._toast = ""; }, 2500);
|
||||
}
|
||||
}
|
||||
|
||||
private async _reanalyzeInterval(): Promise<void> {
|
||||
if (!this._entryId || !this._taskId) return;
|
||||
this._busy = true;
|
||||
this._error = "";
|
||||
try {
|
||||
const r = await this.hass.connection.sendMessagePromise<{
|
||||
recommended_interval: number | null;
|
||||
confidence: string;
|
||||
data_points: number;
|
||||
}>({
|
||||
type: "maintenance_supporter/task/analyze_interval",
|
||||
entry_id: this._entryId,
|
||||
task_id: this._taskId,
|
||||
});
|
||||
this._toast = r.recommended_interval
|
||||
// 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)}: ${formatInterval(r.recommended_interval, "days", this._lang)} (${r.data_points} pts)`
|
||||
: t("reanalyze_insufficient_data", this._lang);
|
||||
await this._loadTask();
|
||||
setTimeout(() => { this._toast = ""; }, 3500);
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._busy = false;
|
||||
}
|
||||
const r = await this._runWs<{
|
||||
recommended_interval: number | null;
|
||||
confidence: string;
|
||||
data_points: number;
|
||||
}>({
|
||||
type: "maintenance_supporter/task/analyze_interval",
|
||||
entry_id: this._entryId,
|
||||
task_id: this._taskId,
|
||||
});
|
||||
if (r === undefined) return;
|
||||
this._showToast(r?.recommended_interval
|
||||
// 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)}: ${formatInterval(r.recommended_interval, "days", this._lang)} (${r.data_points} pts)`
|
||||
: t("reanalyze_insufficient_data", this._lang));
|
||||
await this._loadTask();
|
||||
}
|
||||
|
||||
private _onEditHistoryEntry(entry: HistoryEntry): void {
|
||||
@@ -378,11 +396,13 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
|
||||
task.interval_confidence || "medium", L,
|
||||
)}
|
||||
<div class="recommendation-actions">
|
||||
<button class="btn primary"
|
||||
@click=${this._applySuggestion} ?disabled=${this._busy}>
|
||||
<ha-icon icon="mdi:check"></ha-icon>
|
||||
${t("apply_suggestion", L)}
|
||||
</button>
|
||||
${canWrite(this.hass?.user, this._access)
|
||||
? html`<button class="btn primary qa-apply-suggestion"
|
||||
@click=${this._applySuggestion} ?disabled=${this._busy}>
|
||||
<ha-icon icon="mdi:check"></ha-icon>
|
||||
${t("apply_suggestion", L)}
|
||||
</button>`
|
||||
: nothing /* task/apply_suggestion is write tier */}
|
||||
<button class="btn"
|
||||
@click=${this._reanalyzeInterval} ?disabled=${this._busy}>
|
||||
<ha-icon icon="mdi:refresh"></ha-icon>
|
||||
@@ -440,55 +460,51 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
|
||||
* existing history-edit dialog (which lives in the same dialog-mount). */
|
||||
private _renderDetails(task: MaintenanceTask) {
|
||||
const L = this._lang;
|
||||
// The list payload carries only the most recent entries (payload diet,
|
||||
// 20) — the stats come from the server's totals over the WHOLE history,
|
||||
// not from that window: a task done 30 times read "20" here (DRY audit
|
||||
// 2026-09-26). The list itself stays the recent window, newest first by
|
||||
// timestamp (a backdated #133 completion is appended last).
|
||||
const history = (task.history || []) as HistoryEntry[];
|
||||
const completed = history.filter((h) => h.type === "completed");
|
||||
const totalCost = completed.reduce(
|
||||
(s, h) => s + (typeof h.cost === "number" ? h.cost : 0),
|
||||
0,
|
||||
);
|
||||
const avgDuration = (() => {
|
||||
const durs = completed
|
||||
.map((h) => (typeof h.duration === "number" ? h.duration : null))
|
||||
.filter((d): d is number => d != null);
|
||||
if (!durs.length) return null;
|
||||
return Math.round(durs.reduce((s, d) => s + d, 0) / durs.length);
|
||||
})();
|
||||
const totalEntries = task.history_count ?? history.length;
|
||||
const recent = newestFirst(history).slice(0, 20);
|
||||
|
||||
return html`
|
||||
<div class="details">
|
||||
<div class="stats-grid">
|
||||
<div class="stat">
|
||||
<span class="stat-label">${t("times_performed", L)}</span>
|
||||
<span class="stat-value">${completed.length}</span>
|
||||
<span class="stat-value">${task.times_performed ?? 0}</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">${t("total_cost", L)}</span>
|
||||
<span class="stat-value">${formatCost(totalCost, this._currencySymbol, L)}</span>
|
||||
<span class="stat-value">${formatCost(task.total_cost ?? 0, this._currencySymbol, L)}</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">${t("avg_duration", L)}</span>
|
||||
<span class="stat-value">${formatDuration(avgDuration, L)}</span>
|
||||
<span class="stat-value">${formatDuration(task.average_duration != null ? Math.round(task.average_duration) : null, L)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="history-header">
|
||||
<strong>${t("history", L)}</strong>
|
||||
<span class="history-count">${history.length}</span>
|
||||
<span class="history-count">${totalEntries}</span>
|
||||
</div>
|
||||
${history.length === 0
|
||||
? html`<div class="history-empty">${t("history_empty", L)}</div>`
|
||||
: html`
|
||||
<div class="history-list">
|
||||
${[...history].reverse().slice(0, 20).map((entry) => renderHistoryEntry(entry, {
|
||||
${recent.map((entry) => renderHistoryEntry(entry, {
|
||||
lang: L,
|
||||
hass: this.hass,
|
||||
currencySymbol: this._currencySymbol,
|
||||
openEdit: (e) => this._onEditHistoryEntry(e),
|
||||
// task/history/update is write tier: no pencil otherwise.
|
||||
openEdit: canWrite(this.hass?.user, this._access) ? (e) => this._onEditHistoryEntry(e) : undefined,
|
||||
readingUnit: task.reading_unit,
|
||||
readingSlotDelta: (e, slotId) => readingSlotDelta(history, e, slotId),
|
||||
taskRef: this._taskRef,
|
||||
}, { compact: true }))}
|
||||
${history.length > 20
|
||||
? html`<div class="history-more">… +${history.length - 20} ${t("older_entries", L)}</div>`
|
||||
${totalEntries > recent.length
|
||||
? html`<div class="history-more">… +${totalEntries - recent.length} ${t("older_entries", L)}</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
`}
|
||||
@@ -500,17 +516,16 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
|
||||
if (!this._open) return nothing;
|
||||
const L = this._lang;
|
||||
const task = this._task;
|
||||
const isAdmin = (this.hass?.user?.is_admin ?? true) as boolean;
|
||||
const writer = canWrite(this.hass?.user, this._access);
|
||||
|
||||
return html`
|
||||
<div class="backdrop" @click=${this.close}></div>
|
||||
<div class="dialog" role="dialog" aria-modal="true">
|
||||
return renderModalShell(() => this.close(), html`
|
||||
${task
|
||||
? html`
|
||||
<div class="header">
|
||||
<div class="title">
|
||||
<span class="status-dot" style="background: ${STATUS_COLORS[task.status] || "#ccc"}"></span>
|
||||
<span class="status-dot" style="background: ${statusColor(task)}"></span>
|
||||
<span class="task-name">${task.name}</span>
|
||||
${renderStatusBadge(task, L)}
|
||||
</div>
|
||||
<div class="object">
|
||||
<button class="link-inline" @click=${() => {
|
||||
@@ -597,30 +612,33 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
|
||||
${t("reset", L)}
|
||||
</ha-button>
|
||||
</div>
|
||||
${isAdmin
|
||||
? html`
|
||||
<div class="actions secondary-row">
|
||||
<ha-button size="small" appearance="outlined" variant="neutral" @click=${this._onEdit} .disabled=${this._busy}>
|
||||
<ha-icon slot="start" icon="mdi:pencil"></ha-icon>
|
||||
${t("edit", L)}
|
||||
</ha-button>
|
||||
<ha-button size="small" appearance="outlined" variant="neutral" @click=${this._onQr} .disabled=${this._busy}>
|
||||
<ha-icon slot="start" icon="mdi:qrcode"></ha-icon>
|
||||
${t("qr_code", L)}
|
||||
</ha-button>
|
||||
<ha-button size="small" appearance="outlined" variant="neutral"
|
||||
@click=${task.archived ? this._onUnarchive : this._onArchive}
|
||||
.disabled=${this._busy}>
|
||||
<ha-icon slot="start" icon="${task.archived ? 'mdi:archive-arrow-up-outline' : 'mdi:archive-outline'}"></ha-icon>
|
||||
${task.archived ? t("unarchive", L) : t("archive", L)}
|
||||
</ha-button>
|
||||
<ha-button size="small" appearance="outlined" variant="danger" class="danger" @click=${this._onDelete} .disabled=${this._busy}>
|
||||
<ha-icon slot="start" icon="mdi:delete"></ha-icon>
|
||||
${t("delete", L)}
|
||||
</ha-button>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
<!-- QR is read tier (the panel offers it to operators too);
|
||||
Edit / Archive / Delete follow canWrite() — the panel's
|
||||
operator delegation, not is_admin alone. -->
|
||||
<div class="actions secondary-row">
|
||||
${writer
|
||||
? html`<ha-button size="small" appearance="outlined" variant="neutral" class="qa-edit" @click=${this._onEdit} .disabled=${this._busy}>
|
||||
<ha-icon slot="start" icon="mdi:pencil"></ha-icon>
|
||||
${t("edit", L)}
|
||||
</ha-button>`
|
||||
: nothing}
|
||||
<ha-button size="small" appearance="outlined" variant="neutral" class="qa-qr" @click=${this._onQr} .disabled=${this._busy}>
|
||||
<ha-icon slot="start" icon="mdi:qrcode"></ha-icon>
|
||||
${t("qr_code", L)}
|
||||
</ha-button>
|
||||
${writer
|
||||
? html`<ha-button size="small" appearance="outlined" variant="neutral" class="qa-archive"
|
||||
@click=${task.archived ? this._onUnarchive : this._onArchive}
|
||||
.disabled=${this._busy}>
|
||||
<ha-icon slot="start" icon="${task.archived ? 'mdi:archive-arrow-up-outline' : 'mdi:archive-outline'}"></ha-icon>
|
||||
${task.archived ? t("unarchive", L) : t("archive", L)}
|
||||
</ha-button>
|
||||
<ha-button size="small" appearance="outlined" variant="danger" class="danger qa-delete" @click=${this._onDelete} .disabled=${this._busy}>
|
||||
<ha-icon slot="start" icon="mdi:delete"></ha-icon>
|
||||
${t("delete", L)}
|
||||
</ha-button>`
|
||||
: nothing}
|
||||
</div>
|
||||
<div class="details-toggle">
|
||||
<button class="link" @click=${() => { this._showDetails = !this._showDetails; }}>
|
||||
<ha-icon icon="${this._showDetails ? 'mdi:chevron-up' : 'mdi:chevron-down'}"></ha-icon>
|
||||
@@ -649,30 +667,18 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
|
||||
</div>
|
||||
`}
|
||||
`
|
||||
: html`<div class="loading">${t("loading", L)}</div>`}
|
||||
</div>
|
||||
`;
|
||||
: this._error
|
||||
? html`<div class="error">${this._error}</div>`
|
||||
: html`<div class="loading">${t("loading", L)}</div>`}
|
||||
`);
|
||||
}
|
||||
|
||||
static styles = [sharedStyles, css`
|
||||
:host { display: contents; }
|
||||
.backdrop {
|
||||
position: fixed; inset: 0; z-index: 100;
|
||||
background: rgba(0,0,0,0.5);
|
||||
}
|
||||
.dialog {
|
||||
position: fixed; left: 50%; top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 95vw; max-width: 460px;
|
||||
max-height: 92vh; overflow: auto;
|
||||
background: var(--card-background-color, var(--ha-card-background, #1c1c1c));
|
||||
color: var(--primary-text-color);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.4);
|
||||
padding: 20px;
|
||||
display: flex; flex-direction: column; gap: 14px;
|
||||
z-index: 101;
|
||||
}
|
||||
protected updated(changed: Map<string, unknown>): void {
|
||||
if (changed.has("_open") && this._open) focusModalShell(this.shadowRoot);
|
||||
}
|
||||
|
||||
static styles = [sharedStyles, modalShellStyles, css`
|
||||
:host { display: contents; --ms-modal-max-width: 460px; }
|
||||
.header { display: flex; flex-direction: column; gap: 6px; }
|
||||
.title { display: flex; align-items: center; gap: 10px; }
|
||||
.status-dot { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }
|
||||
|
||||
+58
-60
@@ -15,7 +15,9 @@ import { LitElement, html, css, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { t, ensureLocale, langOf, syncLocaleFromHass } from "../styles";
|
||||
import { registerCustomCard } from "../helpers/register-card";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { runWs } from "../helpers/ws-run";
|
||||
import { VACATION_BUFFER_DAYS_RANGE } from "../helpers/setting-ranges";
|
||||
import { confirmAction } from "../helpers/confirm";
|
||||
import { sectionCardSharedStyles } from "./section-card-shared-styles";
|
||||
import "./ms-date-field";
|
||||
import type { HomeAssistant } from "../types";
|
||||
@@ -43,7 +45,8 @@ export class MaintenanceVacationSectionCard extends LitElement {
|
||||
@state() private _error = "";
|
||||
@state() private _localStart = "";
|
||||
@state() private _localEnd = "";
|
||||
@state() private _localBuffer = 7;
|
||||
/** Typed text — validated against VACATION_BUFFER_DAYS_RANGE on Save. */
|
||||
@state() private _localBuffer = "3";
|
||||
@state() private _dirty = false;
|
||||
|
||||
private _loaded = false;
|
||||
@@ -76,72 +79,68 @@ export class MaintenanceVacationSectionCard extends LitElement {
|
||||
}
|
||||
|
||||
private async _load(): Promise<void> {
|
||||
try {
|
||||
const r = await this.hass.connection.sendMessagePromise<VacationState>({
|
||||
type: "maintenance_supporter/vacation/state",
|
||||
});
|
||||
this._state = r;
|
||||
this._localStart = r.start || "";
|
||||
this._localEnd = r.end || "";
|
||||
this._localBuffer = r.buffer_days ?? 7;
|
||||
this._dirty = false;
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
}
|
||||
const r = await runWs<VacationState>(this, { type: "maintenance_supporter/vacation/state" }, {
|
||||
onError: (m) => { this._error = m; },
|
||||
});
|
||||
if (!r) return;
|
||||
this._state = r;
|
||||
this._localStart = r.start || "";
|
||||
this._localEnd = r.end || "";
|
||||
this._localBuffer = String(r.buffer_days ?? 3);
|
||||
this._dirty = false;
|
||||
}
|
||||
|
||||
/** One vacation write — helpers/ws-run runWs with the busy flag and the
|
||||
* error line; resolves the new state (undefined when refused). */
|
||||
private _act(payload: Record<string, unknown>): Promise<VacationState | null | undefined> {
|
||||
this._error = "";
|
||||
return runWs<VacationState>(this, payload, {
|
||||
busy: (b) => { this._busy = b; },
|
||||
onError: (m) => { this._error = m; },
|
||||
});
|
||||
}
|
||||
|
||||
private async _toggleEnabled(on: boolean): Promise<void> {
|
||||
this._busy = true;
|
||||
this._error = "";
|
||||
try {
|
||||
const r = await this.hass.connection.sendMessagePromise<VacationState>({
|
||||
type: "maintenance_supporter/vacation/update",
|
||||
enabled: on,
|
||||
});
|
||||
this._state = r;
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._busy = false;
|
||||
}
|
||||
const r = await this._act({ type: "maintenance_supporter/vacation/update", enabled: on });
|
||||
if (r) this._state = r;
|
||||
}
|
||||
|
||||
private async _save(): Promise<void> {
|
||||
if (!this._isAdmin) return;
|
||||
this._busy = true;
|
||||
this._error = "";
|
||||
try {
|
||||
const r = await this.hass.connection.sendMessagePromise<VacationState>({
|
||||
type: "maintenance_supporter/vacation/update",
|
||||
start: this._localStart || null,
|
||||
end: this._localEnd || null,
|
||||
buffer_days: this._localBuffer,
|
||||
});
|
||||
this._state = r;
|
||||
this._dirty = false;
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._busy = false;
|
||||
// Same range + message as the settings view (DRY audit 2026-09-26): an
|
||||
// out-of-range buffer is named up front instead of round-tripping into
|
||||
// a voluptuous error.
|
||||
const [min, max] = VACATION_BUFFER_DAYS_RANGE;
|
||||
const buffer = Number(this._localBuffer);
|
||||
if (this._localBuffer.trim() === "" || !Number.isInteger(buffer) || buffer < min || buffer > max) {
|
||||
this._error = t("settings_value_out_of_range", this._lang).replace("{min}", String(min)).replace("{max}", String(max));
|
||||
return;
|
||||
}
|
||||
const r = await this._act({
|
||||
type: "maintenance_supporter/vacation/update",
|
||||
start: this._localStart || null,
|
||||
end: this._localEnd || null,
|
||||
buffer_days: buffer,
|
||||
});
|
||||
if (!r) return;
|
||||
this._state = r;
|
||||
this._dirty = false;
|
||||
}
|
||||
|
||||
private async _endNow(): Promise<void> {
|
||||
if (!this._isAdmin) return;
|
||||
if (!window.confirm(t("vacation_end_now_confirm", this._lang))) return;
|
||||
this._busy = true;
|
||||
try {
|
||||
const r = await this.hass.connection.sendMessagePromise<VacationState>({
|
||||
type: "maintenance_supporter/vacation/end_now",
|
||||
});
|
||||
this._state = r;
|
||||
this._localStart = r.start || "";
|
||||
this._localEnd = r.end || "";
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._busy = false;
|
||||
}
|
||||
// The confirm dialog, not the browser's native modal (DRY audit 2026-09-26).
|
||||
const confirmed = await confirmAction(this.hass, {
|
||||
title: t("vacation_end_now", this._lang),
|
||||
message: t("vacation_end_now_confirm", this._lang),
|
||||
confirmText: t("vacation_end_now", this._lang),
|
||||
});
|
||||
if (!confirmed) return;
|
||||
const r = await this._act({ type: "maintenance_supporter/vacation/end_now" });
|
||||
if (!r) return;
|
||||
this._state = r;
|
||||
this._localStart = r.start || "";
|
||||
this._localEnd = r.end || "";
|
||||
}
|
||||
|
||||
private _onDeepLink(): void {
|
||||
@@ -224,12 +223,11 @@ export class MaintenanceVacationSectionCard extends LitElement {
|
||||
</div>
|
||||
<div class="date-field buffer">
|
||||
<label>${t("vacation_buffer", L)}</label>
|
||||
<input type="number" min="0" max="14"
|
||||
.value=${String(this._localBuffer)}
|
||||
<input type="number" min=${VACATION_BUFFER_DAYS_RANGE[0]} max=${VACATION_BUFFER_DAYS_RANGE[1]}
|
||||
.value=${this._localBuffer}
|
||||
?disabled=${this._busy}
|
||||
@input=${(e: Event) => {
|
||||
this._localBuffer = parseInt(
|
||||
(e.target as HTMLInputElement).value, 10) || 0;
|
||||
this._localBuffer = (e.target as HTMLInputElement).value;
|
||||
this._dirty = true;
|
||||
}} />
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
import type { MaintenanceObjectResponse } from "../types";
|
||||
import { intervalSpanDays } from "./interval";
|
||||
import { statusRank } from "../status-constants";
|
||||
|
||||
export const MAX_OCCURRENCES_PER_TASK = 5;
|
||||
|
||||
@@ -73,6 +74,14 @@ export function isoDateLocal(d: Date): string {
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
/** The local wall-clock minute as "YYYY-MM-DDTHH:MM:00" (seconds zeroed) —
|
||||
* the value shape of a datetime field; the date half is isoDateLocal. */
|
||||
export function isoMinuteLocal(d: Date): string {
|
||||
const hh = String(d.getHours()).padStart(2, "0");
|
||||
const mm = String(d.getMinutes()).padStart(2, "0");
|
||||
return `${isoDateLocal(d)}T${hh}:${mm}:00`;
|
||||
}
|
||||
|
||||
/** Build the list of N consecutive ISO dates starting today (local). */
|
||||
export function buildWindowDates(today: Date, windowDays: number): string[] {
|
||||
const out: string[] = [];
|
||||
@@ -198,14 +207,6 @@ function projectTask(input: ProjectionInput): CalendarEvent[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Status sort priority: lower = shown first. */
|
||||
const STATUS_RANK: Record<string, number> = {
|
||||
overdue: 0,
|
||||
triggered: 1,
|
||||
due_soon: 2,
|
||||
ok: 3,
|
||||
};
|
||||
|
||||
/**
|
||||
* Main entry point: build the day-bucketed event list for the Calendar tab.
|
||||
*
|
||||
@@ -252,8 +253,8 @@ export function buildCalendarBuckets(
|
||||
// Sort within day: status priority first, then projected last, then by name
|
||||
for (const [, evs] of byDate) {
|
||||
evs.sort((a, b) => {
|
||||
const rA = STATUS_RANK[a.status] ?? 99;
|
||||
const rB = STATUS_RANK[b.status] ?? 99;
|
||||
const rA = statusRank(a.status);
|
||||
const rB = statusRank(b.status);
|
||||
if (rA !== rB) return rA - rB;
|
||||
if (a.projected !== b.projected) return a.projected ? 1 : -1;
|
||||
const cmp = a.object_name.localeCompare(b.object_name);
|
||||
@@ -288,7 +289,7 @@ const HISTORY_TYPE_TO_STATUS: Record<string, string> = {
|
||||
trigger_removed: "ok", // config change, not a due/triggered event
|
||||
};
|
||||
|
||||
interface HistoryEntryShape {
|
||||
export interface HistoryEntryShape {
|
||||
timestamp?: string;
|
||||
type?: string;
|
||||
notes?: string;
|
||||
@@ -296,6 +297,52 @@ interface HistoryEntryShape {
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
/** `${entry_id}/${task_id}` — the key of a task's fetched full history. */
|
||||
export function pastHistoryKey(entryId: string, taskId: string): string {
|
||||
return `${entryId}/${taskId}`;
|
||||
}
|
||||
|
||||
/** Tasks whose LIST history (the payload diet keeps the last 20 entries)
|
||||
* may be missing entries inside the past window: more entries exist
|
||||
* (`history_count`) and the oldest listed one is still inside the window.
|
||||
* A daily task showed only its last 20 days in the 30/90-day past view
|
||||
* (DRY audit 2026-09-26). `sig` changes when the task gains / loses
|
||||
* entries OR a listed entry changes (an edit keeps the count — the card
|
||||
* kept painting the pre-edit notes / cost / day, bug audit 2026-09-26 #2),
|
||||
* so the card refetches only then. */
|
||||
export function pastHistoryGaps(
|
||||
objects: MaintenanceObjectResponse[],
|
||||
today: Date,
|
||||
pastDays: number,
|
||||
): Array<{ entryId: string; taskId: string; key: string; sig: string }> {
|
||||
const windowStart = buildPastWindowDates(today, pastDays)[0];
|
||||
const out: Array<{ entryId: string; taskId: string; key: string; sig: string }> = [];
|
||||
for (const obj of objects) {
|
||||
for (const task of obj.tasks || []) {
|
||||
const listed = (task.history || []) as HistoryEntryShape[];
|
||||
const count = task.history_count ?? listed.length;
|
||||
if (count <= listed.length) continue;
|
||||
const stamps = listed.map((h) => (typeof h?.timestamp === "string" ? h.timestamp : "")).filter(Boolean);
|
||||
const oldest = stamps.length ? stamps.reduce((a, b) => (a < b ? a : b)).slice(0, 10) : "";
|
||||
if (oldest && oldest < windowStart) continue; // the window is fully listed
|
||||
const key = pastHistoryKey(obj.entry_id, task.id);
|
||||
out.push({ entryId: obj.entry_id, taskId: task.id, key, sig: `${key}:${count}:${contentHash(JSON.stringify(listed))}` });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** FNV-1a (32 bit) of a string, base 36 — a cheap change detector for the
|
||||
* signature above, not a security primitive. */
|
||||
function contentHash(text: string): string {
|
||||
let h = 0x811c9dc5;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
h ^= text.charCodeAt(i);
|
||||
h = Math.imul(h, 0x01000193);
|
||||
}
|
||||
return (h >>> 0).toString(36);
|
||||
}
|
||||
|
||||
/** Build a list of N consecutive ISO dates ending today (local). */
|
||||
export function buildPastWindowDates(today: Date, pastDays: number): string[] {
|
||||
const out: string[] = [];
|
||||
@@ -320,6 +367,9 @@ export function buildPastBuckets(
|
||||
today: Date,
|
||||
pastDays: number,
|
||||
userFilter: string | null = null,
|
||||
/** Full histories fetched for the tasks `pastHistoryGaps` named, keyed by
|
||||
* `pastHistoryKey` — they replace the windowed list history. */
|
||||
fullHistory: Readonly<Record<string, HistoryEntryShape[]>> = {},
|
||||
): CalendarDayBucket[] {
|
||||
const days = buildPastWindowDates(today, pastDays);
|
||||
const windowStart = days[0];
|
||||
@@ -334,7 +384,7 @@ export function buildPastBuckets(
|
||||
const tasks = obj.tasks || [];
|
||||
for (const task of tasks) {
|
||||
if (userFilter && task.responsible_user_id !== userFilter) continue;
|
||||
const history = (task.history || []) as HistoryEntryShape[];
|
||||
const history = fullHistory[pastHistoryKey(entryId, task.id)] ?? ((task.history || []) as HistoryEntryShape[]);
|
||||
for (const h of history) {
|
||||
if (typeof h?.timestamp !== "string") continue;
|
||||
const dateKey = h.timestamp.slice(0, 10); // YYYY-MM-DD
|
||||
|
||||
@@ -17,8 +17,10 @@ export const LS_KEYS = {
|
||||
batteryRosterOpen: "msp-bf-roster-open",
|
||||
/** #164: how the object's document list is ordered (newest | oldest | title | category). */
|
||||
docSort: "msp-doc-sort",
|
||||
/** #161: the camera (deviceId) the in-app viewfinder should open with. */
|
||||
cameraDevice: "msp-camera-device",
|
||||
/** #161: the camera the in-app viewfinder should open with, by LABEL
|
||||
* ("camera2 0, facing back"). Replaces the old msp-camera-device: the
|
||||
* Android WebView salts device ids per page, so a stored id never matched. */
|
||||
cameraLabel: "msp-camera-label",
|
||||
overviewTab: "msp-overview-tab",
|
||||
collapsedSections: "msp-collapsed-sections",
|
||||
chartRange: "msp-chart-range",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
*/
|
||||
|
||||
import type { MaintenanceTask } from "../types";
|
||||
import { isoDateLocal } from "./calendar-bucket";
|
||||
|
||||
export interface WorksheetLabels {
|
||||
title: string; // "Work sheet"
|
||||
@@ -46,6 +47,16 @@ export interface WorksheetExcerpt {
|
||||
vendorBase?: string;
|
||||
}
|
||||
|
||||
/** The printing day in the USER's calendar and date format. The raw
|
||||
* `nowIso.slice(0, 10)` was the UTC day (`toISOString()`), so a sheet
|
||||
* printed after midnight east of UTC — or in the evening west of it —
|
||||
* carried the wrong date, and in ISO order regardless of the profile's
|
||||
* date format (bug audit 2026-09-26). */
|
||||
function printedOn(nowIso: string, formatDate: (iso: string) => string): string {
|
||||
const d = new Date(nowIso);
|
||||
return Number.isNaN(d.getTime()) ? nowIso.slice(0, 10) : formatDate(isoDateLocal(d));
|
||||
}
|
||||
|
||||
const esc = (v: unknown): string =>
|
||||
String(v ?? "").replace(/[&<>"']/g, (c) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] as string);
|
||||
@@ -164,6 +175,6 @@ export function buildTaskWorksheetHtml(
|
||||
}
|
||||
} catch (e) { console.warn("excerpt inline render failed", e); }
|
||||
</script>` : ""}` : ""}
|
||||
<footer>${esc(objectName)} · ${esc(task.name)} · ${esc(L.printedOn)} ${esc(nowIso.slice(0, 10))}</footer>
|
||||
<footer>${esc(objectName)} · ${esc(task.name)} · ${esc(L.printedOn)} ${esc(printedOn(nowIso, formatDate))}</footer>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"templates_from": "Ze šablony",
|
||||
"templates_title": "Začít ze šablony",
|
||||
"templates_task_count": "{n} úkolů",
|
||||
"templates_task_count_one": "1 úkol",
|
||||
"template_created": "Vytvořeno ze šablony",
|
||||
"onboard_hint": "Přidejte první objekt a začněte sledovat údržbu.",
|
||||
"edit": "Upravit",
|
||||
@@ -44,6 +45,7 @@
|
||||
"bulk_delete_objects_confirm": "Smazat {n} objektů včetně všech jejich úkolů? Archivace místo toho zachová historii.",
|
||||
"bulk_objects_deleted": "Smazáno objektů: {n}",
|
||||
"bulk_objects_archived": "Archivováno objektů: {n}",
|
||||
"bulk_failed": "Nezdařilo se: {n} — {reason}",
|
||||
"completing": "Dokončování…",
|
||||
"interval": "Interval",
|
||||
"warning": "Upozornění",
|
||||
@@ -198,8 +200,6 @@
|
||||
"show_all": "Zobrazit vše",
|
||||
"cost_duration_chart": "Náklady a doba trvání",
|
||||
"installed": "Nainstalováno",
|
||||
"confirm_delete_object": "Smazat tento objekt a všechny jeho úkoly?",
|
||||
"confirm_delete_task": "Smazat tento úkol?",
|
||||
"min": "Min",
|
||||
"max": "Max",
|
||||
"save": "Uložit",
|
||||
@@ -241,7 +241,6 @@
|
||||
"sort_task_name": "Název úkolu",
|
||||
"all_objects": "Všechny objekty",
|
||||
"all_parts": "Všechny díly",
|
||||
"tasks_lower": "úkolů",
|
||||
"no_tasks_yet": "Zatím žádné úkoly",
|
||||
"add_first_task": "Přidat první úkol",
|
||||
"trigger_configuration": "Konfigurace spouštěče",
|
||||
@@ -298,6 +297,7 @@
|
||||
"tab_calendar": "Kalendář",
|
||||
"cal_no_events": "Bez údržby",
|
||||
"cal_every_n_days": "každých {n} dní",
|
||||
"cal_every_day": "každý den",
|
||||
"cal_source_time": "Čas",
|
||||
"cal_source_time_adaptive": "Čas (adaptivní)",
|
||||
"cal_source_sensor": "Senzor",
|
||||
@@ -713,7 +713,6 @@
|
||||
"budget_open_panel": "Otevřít v panelu",
|
||||
"groups_empty": "Zatím žádné skupiny.",
|
||||
"group_new_placeholder": "Přidat skupinu…",
|
||||
"group_delete_confirm": "Smazat skupinu „{name}\"?",
|
||||
"groups_manage_tasks": "Spravovat přiřazení úkolů…",
|
||||
"groups_open_panel": "Otevřít v panelu",
|
||||
"on_complete_action_target_hint": "Poznámka: doména entity musí odpovídat službě — např. 'button.press' funguje jen na button.*, 'counter.increment' jen na counter.*, 'input_button.press' jen na input_button.* atd. Při neshodě akce tiše selže (HA zaznamená 'Referenced entities ... missing or not currently available').",
|
||||
@@ -764,6 +763,7 @@
|
||||
"doc_delete_confirm": "Smazat „{name}“?",
|
||||
"doc_too_large": "Soubor je příliš velký (max. 25 MB).",
|
||||
"doc_upload_failed": "Nahrání selhalo.",
|
||||
"docs_archive_too_large": "Archiv je příliš velký (max. {max}).",
|
||||
"completion_photos_optional": "Fotky z dokončení (volitelné)",
|
||||
"completion_photos": "Fotky z dokončení",
|
||||
"choose_photos": "Vybrat fotky",
|
||||
@@ -791,7 +791,11 @@
|
||||
"doc_camera": "Vyfotit",
|
||||
"camera_capture_shoot": "Vyfotit",
|
||||
"camera_switch_lens": "Přepnout kameru",
|
||||
"camera_switch_failed": "Žádná jiná kamera neodpověděla – tento telefon uvolňuje jen jednu. Pro druhý objektiv použijte výběr souboru.",
|
||||
"camera_switching": "Přepínám kameru…",
|
||||
"camera_not_answered": "Tato kamera neodpověděla. Klepnutím na přepnutí zkuste další, nebo použijte výběr souboru.",
|
||||
"camera_diagnostics": "Podrobnosti pro hlášení chyby",
|
||||
"camera_copy": "Kopírovat",
|
||||
"camera_copied": "Zkopírováno",
|
||||
"doc_drop_hint": "Přetáhněte soubory sem",
|
||||
"doc_task_none": "Žádné dokumenty spojené s tímto úkolem.",
|
||||
"doc_link_existing": "Připojit dokument…",
|
||||
@@ -844,6 +848,55 @@
|
||||
"reading_below_last": "Nižší než poslední odečet ({value})",
|
||||
"settings_templates_label": "Galerie šablon",
|
||||
"settings_templates_hint": "Odškrtněte šablony, které nikdy nevyužijete — zmizí z výběru „Ze šablony\" (panel i config flow). Nic jiného se nemění; kdykoli je lze znovu zapnout.",
|
||||
"home_profile_title": "Profil bydlení",
|
||||
"home_profile_hint": "Doporučuje šablony a nastavuje sezónní okna podle vašeho klimatu a polokoule. Vše se určuje lokálně z vašich podlaží, oblastí a polohy domova.",
|
||||
"home_type_label": "Typ bydlení",
|
||||
"home_type_auto": "Automaticky ({detected})",
|
||||
"home_region_label": "Stát, provincie nebo region",
|
||||
"home_region_none": "nenalezeno",
|
||||
"home_region_hint": "Některá pravidla se v rámci země liší – v jednom státě každoroční technická kontrola vozidel, v sousedním žádná. Určeno z polohy domova.",
|
||||
"home_dwelling_house": "Dům",
|
||||
"home_dwelling_apartment": "Byt",
|
||||
"home_dwelling_unknown": "nerozpoznáno",
|
||||
"home_detected_from": "Rozpoznáno podle: {reasons}",
|
||||
"home_reason_floors": "více podlaží",
|
||||
"home_reason_basement": "sklep",
|
||||
"home_reason_garden": "zahrada",
|
||||
"home_reason_shed": "kůlna",
|
||||
"home_reason_driveway": "příjezdová cesta",
|
||||
"home_reason_garage": "garáž",
|
||||
"home_reason_attic": "půda",
|
||||
"home_reason_lawn_mower": "sekačka",
|
||||
"home_reason_gate": "brána",
|
||||
"home_reason_balcony": "balkon",
|
||||
"home_reason_small_home": "málo místností v jednom podlaží",
|
||||
"home_reason_ups": "záložní zdroj",
|
||||
"home_climate_label": "Klima",
|
||||
"home_climate_value": "Köppen {koppen} · nejchladnější měsíc {cold} °C · nejteplejší měsíc {warm} °C",
|
||||
"home_climate_unknown": "V Home Assistant není nastavena poloha domova",
|
||||
"home_hemisphere_south": "Jižní polokoule — sezónní okna jsou obrácena",
|
||||
"home_trait_freeze": "Mráz v zimě",
|
||||
"home_trait_snow": "Zasněžené zimy",
|
||||
"home_trait_severe_winter": "Tuhé zimy",
|
||||
"home_trait_damp": "Vlhké klima",
|
||||
"home_trait_mediterranean": "Středomořské klima",
|
||||
"home_trait_earthquake": "Seismická oblast",
|
||||
"home_trait_hot_summer": "Horká léta",
|
||||
"home_trait_hot_humid": "Horko a vlhko",
|
||||
"home_trait_hot_dry": "Horko a sucho",
|
||||
"home_trait_termites": "Oblast s termity",
|
||||
"home_trait_wildfire": "Riziko lesních požárů",
|
||||
"home_trait_cyclone": "Oblast hurikánů / tajfunů",
|
||||
"home_trait_tropical": "Tropické klima",
|
||||
"home_trait_radon": "Doporučeno měření radonu",
|
||||
"home_reason_starter": "Základ pro váš domov",
|
||||
"home_reason_country": "Běžné v zemi: {country}",
|
||||
"home_reason_feature": "Nalezeno ve vašem domově: {feature}",
|
||||
"templates_recommended_title": "Doporučeno pro váš domov",
|
||||
"templates_recommended_hint": "Podle profilu bydlení — upravíte v Nastavení.",
|
||||
"templates_legal_hint": "Poznámky k zákonným povinnostem a lhůtám slouží pro orientaci a nejsou právním poradenstvím. Pravidla se liší podle regionu a mění se — rozhodující jsou místní předpisy a pokyny výrobce.",
|
||||
"templates_not_typical": "Pro váš typ bydlení netypické",
|
||||
"templates_set_up": "Již nastaveno",
|
||||
"worksheet": "Pracovní list",
|
||||
"worksheet_scan_view": "Naskenujte pro otevření úkolu",
|
||||
"worksheet_scan_complete": "Naskenujte pro dokončení",
|
||||
@@ -901,6 +954,8 @@
|
||||
"adopt_problem_ok": "ok",
|
||||
"adopt_problem_new_object": "(nový)",
|
||||
"adopt_object_hint": "Dejte několika senzorům stejný název objektu a stanou se jedním objektem; zadejte název existujícího objektu a přidají se tam.",
|
||||
"adopt_summary_title": "Co převzetí změní",
|
||||
"adopt_summary_existing": "„{name}“: úkoly {before} → {after}",
|
||||
"adopt_problem_adopt": "Převzít vybrané",
|
||||
"adopt_problem_done": "Převzato {tasks} senzorů problémů",
|
||||
"views_label": "Zobrazení",
|
||||
@@ -973,6 +1028,15 @@
|
||||
"trend_easing": "Vzdaluje se od prahu",
|
||||
"split_select_hint": "Vyberte úkol a zde se zobrazí podrobnosti",
|
||||
"setups_target_new": "Vytvořit nový: {name}",
|
||||
"setups_target_match": "{name} — shoda: {reasons}",
|
||||
"setups_reason_model": "číslo modelu",
|
||||
"setups_reason_name": "název",
|
||||
"setups_reason_area": "oblast",
|
||||
"setups_reason_sibling": "stejný model na jiném zařízení",
|
||||
"setups_maybe_covered": "Pravděpodobně už existuje jako „{name}“",
|
||||
"setups_already": "Už existuje: {names}",
|
||||
"setups_before_after": "Úkoly u objektu: {before} → {after}",
|
||||
"setups_new_object_count": "Nový objekt „{name}“ · úkoly: {count}",
|
||||
"schedule_preview_title": "Nejbližší termíny",
|
||||
"schedule_preview_ontime": "Za předpokladu včasného dokončení.",
|
||||
"schedule_preview_ends": "(série končí)",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"templates_from": "Fra skabelon",
|
||||
"templates_title": "Start fra en skabelon",
|
||||
"templates_task_count": "{n} opgaver",
|
||||
"templates_task_count_one": "1 opgave",
|
||||
"template_created": "Oprettet fra skabelon",
|
||||
"onboard_hint": "Tilføj dit første objekt for at spore vedligeholdelse.",
|
||||
"edit": "Rediger",
|
||||
@@ -46,6 +47,7 @@
|
||||
"bulk_delete_objects_confirm": "Slet {n} objekter og alle deres opgaver? Arkivering bevarer i stedet historikken.",
|
||||
"bulk_objects_deleted": "{n} objekter slettet",
|
||||
"bulk_objects_archived": "{n} objekter arkiveret",
|
||||
"bulk_failed": "{n} mislykkedes: {reason}",
|
||||
"completing": "Fuldfører…",
|
||||
"interval": "Interval",
|
||||
"warning": "Advarsel",
|
||||
@@ -200,8 +202,6 @@
|
||||
"show_all": "Vis alle",
|
||||
"cost_duration_chart": "Omkostning og varighed",
|
||||
"installed": "Installeret",
|
||||
"confirm_delete_object": "Slet dette objekt og alle dets opgaver?",
|
||||
"confirm_delete_task": "Slet denne opgave?",
|
||||
"min": "Min",
|
||||
"max": "Maks",
|
||||
"save": "Gem",
|
||||
@@ -243,7 +243,6 @@
|
||||
"sort_task_name": "Opgavenavn",
|
||||
"all_objects": "Alle objekter",
|
||||
"all_parts": "Alle dele",
|
||||
"tasks_lower": "opgaver",
|
||||
"no_tasks_yet": "Ingen opgaver endnu",
|
||||
"add_first_task": "Tilføj første opgave",
|
||||
"trigger_configuration": "Udløserkonfiguration",
|
||||
@@ -300,6 +299,7 @@
|
||||
"tab_calendar": "Kalender",
|
||||
"cal_no_events": "Ingen vedligeholdelse",
|
||||
"cal_every_n_days": "hver {n}. dag",
|
||||
"cal_every_day": "hver dag",
|
||||
"cal_source_time": "Tidsbaseret",
|
||||
"cal_source_time_adaptive": "Tidsbaseret (adaptiv)",
|
||||
"cal_source_sensor": "Sensorbaseret",
|
||||
@@ -685,7 +685,6 @@
|
||||
"budget_open_panel": "Åbn i panel",
|
||||
"groups_empty": "Ingen grupper endnu.",
|
||||
"group_new_placeholder": "Tilføj gruppe…",
|
||||
"group_delete_confirm": "Slet gruppen \"{name}\"?",
|
||||
"groups_manage_tasks": "Administrer opgavetildelinger…",
|
||||
"groups_open_panel": "Åbn i panel",
|
||||
"unassigned": "Ikke tildelt",
|
||||
@@ -764,6 +763,7 @@
|
||||
"doc_delete_confirm": "Slet \"{name}\"?",
|
||||
"doc_too_large": "Filen er for stor (maks. 25 MB).",
|
||||
"doc_upload_failed": "Upload mislykkedes.",
|
||||
"docs_archive_too_large": "Arkivet er for stort (maks. {max}).",
|
||||
"completion_photos_optional": "Fotos ved fuldførelse (valgfrit)",
|
||||
"completion_photos": "Fotos ved fuldførelse",
|
||||
"choose_photos": "Vælg fotos",
|
||||
@@ -791,7 +791,11 @@
|
||||
"doc_camera": "Tag billede",
|
||||
"camera_capture_shoot": "Tag billede",
|
||||
"camera_switch_lens": "Skift kamera",
|
||||
"camera_switch_failed": "Intet andet kamera svarede — denne telefon frigiver kun ét. Brug filvælgeren til det andet objektiv.",
|
||||
"camera_switching": "Skifter kamera…",
|
||||
"camera_not_answered": "Dette kamera svarede ikke. Tryk på skift igen for at prøve det næste, eller brug filvælgeren.",
|
||||
"camera_diagnostics": "Detaljer til en fejlrapport",
|
||||
"camera_copy": "Kopiér",
|
||||
"camera_copied": "Kopieret",
|
||||
"doc_drop_hint": "Slip filer her",
|
||||
"doc_task_none": "Ingen dokumenter knyttet til denne opgave.",
|
||||
"doc_link_existing": "Knyt et dokument…",
|
||||
@@ -844,6 +848,55 @@
|
||||
"reading_below_last": "Lavere end sidste aflæsning ({value})",
|
||||
"settings_templates_label": "Skabelongalleri",
|
||||
"settings_templates_hint": "Fravælg skabeloner, du aldrig får brug for — de forsvinder fra \"Fra skabelon\"-vælgerne (panel og config flow). Intet andet ændres; kan genaktiveres når som helst.",
|
||||
"home_profile_title": "Boligprofil",
|
||||
"home_profile_hint": "Anbefaler skabeloner og indstiller sæsonvinduer efter dit klima og din halvkugle. Alt udregnes lokalt ud fra dine etager, områder og boligens placering.",
|
||||
"home_type_label": "Boligtype",
|
||||
"home_type_auto": "Automatisk ({detected})",
|
||||
"home_region_label": "Delstat, provins eller region",
|
||||
"home_region_none": "ikke fundet",
|
||||
"home_region_hint": "Nogle regler varierer inden for et land – årligt køretøjssyn i én delstat, intet i den næste. Fundet ud fra hjemmets placering.",
|
||||
"home_dwelling_house": "Hus",
|
||||
"home_dwelling_apartment": "Lejlighed",
|
||||
"home_dwelling_unknown": "ikke genkendt",
|
||||
"home_detected_from": "Genkendt ud fra: {reasons}",
|
||||
"home_reason_floors": "flere etager",
|
||||
"home_reason_basement": "en kælder",
|
||||
"home_reason_garden": "en have",
|
||||
"home_reason_shed": "et skur",
|
||||
"home_reason_driveway": "en indkørsel",
|
||||
"home_reason_garage": "en garage",
|
||||
"home_reason_attic": "et loft",
|
||||
"home_reason_lawn_mower": "en plæneklipper",
|
||||
"home_reason_gate": "en låge",
|
||||
"home_reason_balcony": "en altan",
|
||||
"home_reason_small_home": "få rum på én etage",
|
||||
"home_reason_ups": "en UPS-enhed",
|
||||
"home_climate_label": "Klima",
|
||||
"home_climate_value": "Köppen {koppen} · koldeste måned {cold} °C · varmeste måned {warm} °C",
|
||||
"home_climate_unknown": "Ingen hjemmeplacering angivet i Home Assistant",
|
||||
"home_hemisphere_south": "Den sydlige halvkugle — sæsonvinduer spejlvendes",
|
||||
"home_trait_freeze": "Frost om vinteren",
|
||||
"home_trait_snow": "Snerige vintre",
|
||||
"home_trait_severe_winter": "Strenge vintre",
|
||||
"home_trait_damp": "Fugtigt klima",
|
||||
"home_trait_mediterranean": "Middelhavsklima",
|
||||
"home_trait_earthquake": "Jordskælvsområde",
|
||||
"home_trait_hot_summer": "Varme somre",
|
||||
"home_trait_hot_humid": "Varmt og fugtigt",
|
||||
"home_trait_hot_dry": "Varmt og tørt",
|
||||
"home_trait_termites": "Termitområde",
|
||||
"home_trait_wildfire": "Risiko for naturbrande",
|
||||
"home_trait_cyclone": "Orkan-/tyfonområde",
|
||||
"home_trait_tropical": "Tropisk klima",
|
||||
"home_trait_radon": "Radonmåling anbefales",
|
||||
"home_reason_starter": "Det basale til dit hjem",
|
||||
"home_reason_country": "Almindeligt i {country}",
|
||||
"home_reason_feature": "Fundet i dit hjem: {feature}",
|
||||
"templates_recommended_title": "Anbefalet til dit hjem",
|
||||
"templates_recommended_hint": "Baseret på din boligprofil — kan ændres under Indstillinger.",
|
||||
"templates_legal_hint": "Bemærkninger om lovkrav og intervaller er vejledende og ikke juridisk rådgivning. Reglerne varierer fra region til region og ændrer sig — lokale forskrifter og producentens anvisninger har forrang.",
|
||||
"templates_not_typical": "Usædvanligt for din boligtype",
|
||||
"templates_set_up": "Allerede oprettet",
|
||||
"worksheet": "Arbejdsark",
|
||||
"worksheet_scan_view": "Scan for at åbne opgaven",
|
||||
"worksheet_scan_complete": "Scan for at fuldføre",
|
||||
@@ -901,6 +954,8 @@
|
||||
"adopt_problem_ok": "ok",
|
||||
"adopt_problem_new_object": "(ny)",
|
||||
"adopt_object_hint": "Giv flere sensorer samme objektnavn, så bliver de ét objekt; skriv et eksisterende objekts navn for at tilføje dem der.",
|
||||
"adopt_summary_title": "Hvad overtagelsen ændrer",
|
||||
"adopt_summary_existing": "\"{name}\": opgaver {before} → {after}",
|
||||
"adopt_problem_adopt": "Adopter valgte",
|
||||
"adopt_problem_done": "Adopterede {tasks} problemsensor(er)",
|
||||
"views_label": "Visninger",
|
||||
@@ -973,6 +1028,15 @@
|
||||
"trend_easing": "Bevæger sig væk fra tærsklen",
|
||||
"split_select_hint": "Vælg en opgave for at se detaljerne her",
|
||||
"setups_target_new": "Opret ny: {name}",
|
||||
"setups_target_match": "{name} — passer: {reasons}",
|
||||
"setups_reason_model": "modelnummer",
|
||||
"setups_reason_name": "navn",
|
||||
"setups_reason_area": "område",
|
||||
"setups_reason_sibling": "samme model på en anden enhed",
|
||||
"setups_maybe_covered": "Findes sandsynligvis allerede som \"{name}\"",
|
||||
"setups_already": "Findes allerede: {names}",
|
||||
"setups_before_after": "Opgaver på objektet: {before} → {after}",
|
||||
"setups_new_object_count": "Nyt objekt \"{name}\" · opgaver: {count}",
|
||||
"schedule_preview_title": "Kommende datoer",
|
||||
"schedule_preview_ontime": "Forudsat rettidig fuldførelse.",
|
||||
"schedule_preview_ends": "(serien slutter)",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"templates_from": "Aus Vorlage",
|
||||
"templates_title": "Mit einer Vorlage starten",
|
||||
"templates_task_count": "{n} Aufgaben",
|
||||
"templates_task_count_one": "1 Aufgabe",
|
||||
"template_created": "Aus Vorlage erstellt",
|
||||
"onboard_hint": "Füge dein erstes Objekt hinzu, um mit der Wartung zu starten.",
|
||||
"edit": "Bearbeiten",
|
||||
@@ -46,6 +47,7 @@
|
||||
"bulk_delete_objects_confirm": "{n} Objekte mit allen ihren Aufgaben löschen? Archivieren behält stattdessen die Historie.",
|
||||
"bulk_objects_deleted": "{n} Objekte gelöscht",
|
||||
"bulk_objects_archived": "{n} Objekte archiviert",
|
||||
"bulk_failed": "{n} fehlgeschlagen: {reason}",
|
||||
"completing": "Wird erledigt…",
|
||||
"interval": "Intervall",
|
||||
"warning": "Vorwarnung",
|
||||
@@ -200,8 +202,6 @@
|
||||
"show_all": "Alle anzeigen",
|
||||
"cost_duration_chart": "Kosten & Dauer",
|
||||
"installed": "Installiert",
|
||||
"confirm_delete_object": "Dieses Objekt und alle zugehörigen Aufgaben löschen?",
|
||||
"confirm_delete_task": "Diese Aufgabe wirklich löschen?",
|
||||
"min": "Min",
|
||||
"max": "Max",
|
||||
"save": "Speichern",
|
||||
@@ -243,7 +243,6 @@
|
||||
"sort_task_name": "Aufgaben-Name",
|
||||
"all_objects": "Alle Objekte",
|
||||
"all_parts": "Alle Teile",
|
||||
"tasks_lower": "Aufgaben",
|
||||
"no_tasks_yet": "Noch keine Aufgaben",
|
||||
"add_first_task": "Erste Aufgabe hinzufügen",
|
||||
"trigger_configuration": "Trigger-Konfiguration",
|
||||
@@ -300,6 +299,7 @@
|
||||
"tab_calendar": "Kalender",
|
||||
"cal_no_events": "Keine Wartung",
|
||||
"cal_every_n_days": "alle {n} Tage",
|
||||
"cal_every_day": "täglich",
|
||||
"cal_source_time": "Zeit-basiert",
|
||||
"cal_source_time_adaptive": "Zeit-basiert (adaptiv)",
|
||||
"cal_source_sensor": "Sensor-basiert",
|
||||
@@ -685,7 +685,6 @@
|
||||
"budget_open_panel": "Im Panel öffnen",
|
||||
"groups_empty": "Keine Gruppen vorhanden.",
|
||||
"group_new_placeholder": "Gruppe hinzufügen…",
|
||||
"group_delete_confirm": "Gruppe \"{name}\" löschen?",
|
||||
"groups_manage_tasks": "Aufgaben-Zuordnungen verwalten…",
|
||||
"groups_open_panel": "Im Panel öffnen",
|
||||
"unassigned": "Nicht zugewiesen",
|
||||
@@ -764,6 +763,7 @@
|
||||
"doc_delete_confirm": "„{name}“ löschen?",
|
||||
"doc_too_large": "Datei ist zu groß (max. 25 MB).",
|
||||
"doc_upload_failed": "Upload fehlgeschlagen.",
|
||||
"docs_archive_too_large": "Das Archiv ist zu groß (max. {max}).",
|
||||
"completion_photos_optional": "Abschlussfotos (optional)",
|
||||
"completion_photos": "Abschlussfotos",
|
||||
"choose_photos": "Fotos auswählen",
|
||||
@@ -791,7 +791,11 @@
|
||||
"doc_camera": "Foto aufnehmen",
|
||||
"camera_capture_shoot": "Aufnehmen",
|
||||
"camera_switch_lens": "Kamera wechseln",
|
||||
"camera_switch_failed": "Keine andere Kamera hat geantwortet – dieses Telefon gibt nur eine frei. Für das andere Objektiv die Dateiauswahl nutzen.",
|
||||
"camera_switching": "Kamera wird gewechselt …",
|
||||
"camera_not_answered": "Diese Kamera hat nicht geantwortet. Erneut auf Wechseln tippen, um die nächste zu versuchen, oder die Dateiauswahl nutzen.",
|
||||
"camera_diagnostics": "Details für einen Fehlerbericht",
|
||||
"camera_copy": "Kopieren",
|
||||
"camera_copied": "Kopiert",
|
||||
"doc_drop_hint": "Dateien hier ablegen",
|
||||
"doc_task_none": "Keine Dokumente mit dieser Aufgabe verknüpft.",
|
||||
"doc_link_existing": "Dokument verknüpfen…",
|
||||
@@ -844,6 +848,55 @@
|
||||
"reading_below_last": "Kleiner als der letzte Wert ({value})",
|
||||
"settings_templates_label": "Vorlagen-Galerie",
|
||||
"settings_templates_hint": "Vorlagen abwählen, die du nie brauchst — sie verschwinden aus den \"Aus Vorlage\"-Auswahlen (Panel und Config-Flow). Sonst ändert sich nichts; jederzeit wieder aktivierbar.",
|
||||
"home_profile_title": "Wohnprofil",
|
||||
"home_profile_hint": "Empfiehlt Vorlagen und setzt Saisonfenster passend zu Klima und Hemisphäre. Alles wird lokal aus deinen Etagen, Bereichen und dem Standort ermittelt.",
|
||||
"home_type_label": "Wohnform",
|
||||
"home_type_auto": "Automatisch ({detected})",
|
||||
"home_region_label": "Bundesland, Provinz oder Region",
|
||||
"home_region_none": "nicht gefunden",
|
||||
"home_region_hint": "Manche Regeln unterscheiden sich innerhalb eines Landes – jährliche Fahrzeugprüfung im einen Bundesstaat, keine im nächsten. Aus dem Standort deines Zuhauses ermittelt.",
|
||||
"home_dwelling_house": "Haus",
|
||||
"home_dwelling_apartment": "Wohnung",
|
||||
"home_dwelling_unknown": "nicht erkannt",
|
||||
"home_detected_from": "Erkannt an: {reasons}",
|
||||
"home_reason_floors": "mehrere Etagen",
|
||||
"home_reason_basement": "ein Keller",
|
||||
"home_reason_garden": "ein Garten",
|
||||
"home_reason_shed": "ein Schuppen",
|
||||
"home_reason_driveway": "eine Einfahrt",
|
||||
"home_reason_garage": "eine Garage",
|
||||
"home_reason_attic": "ein Dachboden",
|
||||
"home_reason_lawn_mower": "ein Rasenmäher",
|
||||
"home_reason_gate": "ein Tor",
|
||||
"home_reason_balcony": "ein Balkon",
|
||||
"home_reason_small_home": "wenige Räume auf einer Etage",
|
||||
"home_reason_ups": "eine USV",
|
||||
"home_climate_label": "Klima",
|
||||
"home_climate_value": "Köppen {koppen} · kältester Monat {cold} °C · wärmster Monat {warm} °C",
|
||||
"home_climate_unknown": "Kein Standort in Home Assistant hinterlegt",
|
||||
"home_hemisphere_south": "Südhalbkugel — Saisonfenster werden gespiegelt",
|
||||
"home_trait_freeze": "Frost im Winter",
|
||||
"home_trait_snow": "Schneereiche Winter",
|
||||
"home_trait_severe_winter": "Strenge Winter",
|
||||
"home_trait_damp": "Feuchtes Klima",
|
||||
"home_trait_mediterranean": "Mittelmeerklima",
|
||||
"home_trait_earthquake": "Erdbebengebiet",
|
||||
"home_trait_hot_summer": "Heiße Sommer",
|
||||
"home_trait_hot_humid": "Heiß und feucht",
|
||||
"home_trait_hot_dry": "Heiß und trocken",
|
||||
"home_trait_termites": "Termitengebiet",
|
||||
"home_trait_wildfire": "Waldbrandgefahr",
|
||||
"home_trait_cyclone": "Hurrikan-/Taifungebiet",
|
||||
"home_trait_tropical": "Tropisches Klima",
|
||||
"home_trait_radon": "Radontest empfohlen",
|
||||
"home_reason_starter": "Grundausstattung für dein Zuhause",
|
||||
"home_reason_country": "Üblich in {country}",
|
||||
"home_reason_feature": "In deinem Zuhause gefunden: {feature}",
|
||||
"templates_recommended_title": "Empfohlen für dein Zuhause",
|
||||
"templates_recommended_hint": "Auf Basis deines Wohnprofils — anpassbar in den Einstellungen.",
|
||||
"templates_legal_hint": "Hinweise zu Pflichten und Fristen sind Orientierung, keine Rechtsberatung. Regeln unterscheiden sich je nach Region und ändern sich — maßgeblich sind die örtlichen Vorschriften und die Angaben des Herstellers.",
|
||||
"templates_not_typical": "Für deine Wohnform eher untypisch",
|
||||
"templates_set_up": "Bereits eingerichtet",
|
||||
"worksheet": "Arbeitsblatt",
|
||||
"worksheet_scan_view": "Scannen, um die Aufgabe zu öffnen",
|
||||
"worksheet_scan_complete": "Scannen zum Erledigen",
|
||||
@@ -901,6 +954,8 @@
|
||||
"adopt_problem_ok": "ok",
|
||||
"adopt_problem_new_object": "(neu)",
|
||||
"adopt_object_hint": "Gib mehreren Sensoren denselben Objektnamen, dann werden sie ein Objekt; der Name eines vorhandenen Objekts ordnet sie dort zu.",
|
||||
"adopt_summary_title": "Was die Übernahme ändert",
|
||||
"adopt_summary_existing": "„{name}“: Aufgaben {before} → {after}",
|
||||
"adopt_problem_adopt": "Auswahl übernehmen",
|
||||
"adopt_problem_done": "{tasks} Problem-Sensor(en) übernommen",
|
||||
"views_label": "Ansichten",
|
||||
@@ -973,6 +1028,15 @@
|
||||
"trend_easing": "Entfernt sich von der Schwelle",
|
||||
"split_select_hint": "Wähle eine Aufgabe, um hier die Details zu sehen",
|
||||
"setups_target_new": "Neu anlegen: {name}",
|
||||
"setups_target_match": "{name} — passt: {reasons}",
|
||||
"setups_reason_model": "Modellnummer",
|
||||
"setups_reason_name": "Name",
|
||||
"setups_reason_area": "Bereich",
|
||||
"setups_reason_sibling": "gleiches Modell an einem anderen Gerät",
|
||||
"setups_maybe_covered": "Wahrscheinlich schon vorhanden als „{name}“",
|
||||
"setups_already": "Schon vorhanden: {names}",
|
||||
"setups_before_after": "Aufgaben am Objekt: {before} → {after}",
|
||||
"setups_new_object_count": "Neues Objekt „{name}“ · Aufgaben: {count}",
|
||||
"schedule_preview_title": "Nächste Termine",
|
||||
"schedule_preview_ontime": "Bei pünktlichem Abschluss.",
|
||||
"schedule_preview_ends": "(Serie endet)",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"templates_from": "From template",
|
||||
"templates_title": "Start from a template",
|
||||
"templates_task_count": "{n} tasks",
|
||||
"templates_task_count_one": "1 task",
|
||||
"template_created": "Created from template",
|
||||
"onboard_hint": "Add your first object to start tracking maintenance.",
|
||||
"edit": "Edit",
|
||||
@@ -46,6 +47,7 @@
|
||||
"bulk_delete_objects_confirm": "Delete {n} objects and all their tasks? Archiving keeps their history instead.",
|
||||
"bulk_objects_deleted": "{n} objects deleted",
|
||||
"bulk_objects_archived": "{n} objects archived",
|
||||
"bulk_failed": "{n} failed: {reason}",
|
||||
"completing": "Completing…",
|
||||
"interval": "Interval",
|
||||
"warning": "Warning",
|
||||
@@ -200,8 +202,6 @@
|
||||
"show_all": "Show all",
|
||||
"cost_duration_chart": "Cost & Duration",
|
||||
"installed": "Installed",
|
||||
"confirm_delete_object": "Delete this object and all its tasks?",
|
||||
"confirm_delete_task": "Delete this task?",
|
||||
"min": "Min",
|
||||
"max": "Max",
|
||||
"save": "Save",
|
||||
@@ -243,7 +243,6 @@
|
||||
"sort_task_name": "Task name",
|
||||
"all_objects": "All objects",
|
||||
"all_parts": "All parts",
|
||||
"tasks_lower": "tasks",
|
||||
"no_tasks_yet": "No tasks yet",
|
||||
"add_first_task": "Add first task",
|
||||
"trigger_configuration": "Trigger Configuration",
|
||||
@@ -300,6 +299,7 @@
|
||||
"tab_calendar": "Calendar",
|
||||
"cal_no_events": "No maintenance",
|
||||
"cal_every_n_days": "every {n} days",
|
||||
"cal_every_day": "every day",
|
||||
"cal_source_time": "Time-based",
|
||||
"cal_source_time_adaptive": "Time-based (adaptive)",
|
||||
"cal_source_sensor": "Sensor-based",
|
||||
@@ -691,7 +691,6 @@
|
||||
"budget_open_panel": "Open in panel",
|
||||
"groups_empty": "No groups yet.",
|
||||
"group_new_placeholder": "Add group…",
|
||||
"group_delete_confirm": "Delete group \"{name}\"?",
|
||||
"groups_manage_tasks": "Manage task assignments…",
|
||||
"groups_open_panel": "Open in panel",
|
||||
"unassigned": "Unassigned",
|
||||
@@ -770,6 +769,7 @@
|
||||
"doc_delete_confirm": "Delete \"{name}\"?",
|
||||
"doc_too_large": "File is too large (max 25 MB).",
|
||||
"doc_upload_failed": "Upload failed.",
|
||||
"docs_archive_too_large": "The archive is too large (max {max}).",
|
||||
"completion_photos_optional": "Completion photos (optional)",
|
||||
"completion_photos": "Completion photos",
|
||||
"choose_photos": "Choose photos",
|
||||
@@ -797,7 +797,11 @@
|
||||
"doc_camera": "Take photo",
|
||||
"camera_capture_shoot": "Capture",
|
||||
"camera_switch_lens": "Switch camera",
|
||||
"camera_switch_failed": "No other camera answered — this phone hands out only one. Use the file picker for the other lens.",
|
||||
"camera_switching": "Switching camera…",
|
||||
"camera_not_answered": "This camera did not answer. Tap the switch again to try the next one, or use the file picker.",
|
||||
"camera_diagnostics": "Details for a bug report",
|
||||
"camera_copy": "Copy",
|
||||
"camera_copied": "Copied",
|
||||
"doc_drop_hint": "Drop files here",
|
||||
"doc_task_none": "No documents linked to this task.",
|
||||
"doc_link_existing": "Link a document…",
|
||||
@@ -850,6 +854,55 @@
|
||||
"reading_below_last": "Lower than the last reading ({value})",
|
||||
"settings_templates_label": "Template gallery",
|
||||
"settings_templates_hint": "Untick templates you'll never need — they disappear from the \"From template\" pickers (panel and config flow). Nothing else changes; you can re-enable them any time.",
|
||||
"home_profile_title": "Home profile",
|
||||
"home_profile_hint": "Recommends templates and sets seasonal windows for your climate and hemisphere. Everything is worked out locally from your floors, areas and home location.",
|
||||
"home_type_label": "Home type",
|
||||
"home_type_auto": "Automatic ({detected})",
|
||||
"home_region_label": "State, province or region",
|
||||
"home_region_none": "not found",
|
||||
"home_region_hint": "Some rules differ within a country — a yearly vehicle inspection in one state, none in the next. Found from your home location.",
|
||||
"home_dwelling_house": "House",
|
||||
"home_dwelling_apartment": "Apartment",
|
||||
"home_dwelling_unknown": "not recognised",
|
||||
"home_detected_from": "Detected from: {reasons}",
|
||||
"home_reason_floors": "several floors",
|
||||
"home_reason_basement": "a basement",
|
||||
"home_reason_garden": "a garden",
|
||||
"home_reason_shed": "a shed",
|
||||
"home_reason_driveway": "a driveway",
|
||||
"home_reason_garage": "a garage",
|
||||
"home_reason_attic": "an attic",
|
||||
"home_reason_lawn_mower": "a lawn mower",
|
||||
"home_reason_gate": "a gate",
|
||||
"home_reason_balcony": "a balcony",
|
||||
"home_reason_small_home": "few rooms on one floor",
|
||||
"home_reason_ups": "a UPS",
|
||||
"home_climate_label": "Climate",
|
||||
"home_climate_value": "Köppen {koppen} · coldest month {cold} °C · warmest month {warm} °C",
|
||||
"home_climate_unknown": "No home location set in Home Assistant",
|
||||
"home_hemisphere_south": "Southern hemisphere — seasonal windows are mirrored",
|
||||
"home_trait_freeze": "Frost in winter",
|
||||
"home_trait_snow": "Snowy winters",
|
||||
"home_trait_severe_winter": "Severe winters",
|
||||
"home_trait_damp": "Damp climate",
|
||||
"home_trait_mediterranean": "Mediterranean climate",
|
||||
"home_trait_earthquake": "Earthquake region",
|
||||
"home_trait_hot_summer": "Hot summers",
|
||||
"home_trait_hot_humid": "Hot and humid",
|
||||
"home_trait_hot_dry": "Hot and dry",
|
||||
"home_trait_termites": "Termite region",
|
||||
"home_trait_wildfire": "Wildfire risk",
|
||||
"home_trait_cyclone": "Hurricane / typhoon region",
|
||||
"home_trait_tropical": "Tropical climate",
|
||||
"home_trait_radon": "Radon test recommended",
|
||||
"home_reason_starter": "Basics for your home",
|
||||
"home_reason_country": "Common in {country}",
|
||||
"home_reason_feature": "Found in your home: {feature}",
|
||||
"templates_recommended_title": "Recommended for your home",
|
||||
"templates_recommended_hint": "Based on your home profile — adjust it in Settings.",
|
||||
"templates_legal_hint": "Notes on legal duties and intervals are guidance, not legal advice. Rules vary by region and change over time — local regulations and the manufacturer's instructions take precedence.",
|
||||
"templates_not_typical": "Not typical for your home type",
|
||||
"templates_set_up": "Already set up",
|
||||
"worksheet": "Work sheet",
|
||||
"worksheet_scan_view": "Scan to open the task",
|
||||
"worksheet_scan_complete": "Scan to complete",
|
||||
@@ -901,6 +954,8 @@
|
||||
"adopt_problem_ok": "ok",
|
||||
"adopt_problem_new_object": "(new)",
|
||||
"adopt_object_hint": "Give several sensors the same object name and they become one object; type an existing object's name to add them there.",
|
||||
"adopt_summary_title": "What adopting changes",
|
||||
"adopt_summary_existing": "\"{name}\": tasks {before} → {after}",
|
||||
"adopt_problem_adopt": "Adopt selected",
|
||||
"adopt_problem_done": "Adopted {tasks} problem sensor(s)",
|
||||
"views_label": "Views",
|
||||
@@ -973,6 +1028,15 @@
|
||||
"trend_easing": "Easing away from the threshold",
|
||||
"split_select_hint": "Select a task to see its details here",
|
||||
"setups_target_new": "Create new: {name}",
|
||||
"setups_target_match": "{name} — matches: {reasons}",
|
||||
"setups_reason_model": "model number",
|
||||
"setups_reason_name": "name",
|
||||
"setups_reason_area": "area",
|
||||
"setups_reason_sibling": "same model on another device",
|
||||
"setups_maybe_covered": "Probably there already as \"{name}\"",
|
||||
"setups_already": "Already there: {names}",
|
||||
"setups_before_after": "Tasks on the object: {before} → {after}",
|
||||
"setups_new_object_count": "New object \"{name}\" · tasks: {count}",
|
||||
"schedule_preview_title": "Next dates",
|
||||
"schedule_preview_ontime": "Assuming on-time completion.",
|
||||
"schedule_preview_ends": "(series ends)",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"templates_from": "Desde plantilla",
|
||||
"templates_title": "Empezar desde una plantilla",
|
||||
"templates_task_count": "{n} tareas",
|
||||
"templates_task_count_one": "1 tarea",
|
||||
"template_created": "Creado desde plantilla",
|
||||
"onboard_hint": "Añade tu primer objeto para empezar a seguir el mantenimiento.",
|
||||
"edit": "Editar",
|
||||
@@ -44,6 +45,7 @@
|
||||
"bulk_delete_objects_confirm": "¿Eliminar {n} objetos y todas sus tareas? Archivar conserva su historial.",
|
||||
"bulk_objects_deleted": "{n} objetos eliminados",
|
||||
"bulk_objects_archived": "{n} objetos archivados",
|
||||
"bulk_failed": "{n} fallidos: {reason}",
|
||||
"completing": "Completando…",
|
||||
"interval": "Intervalo",
|
||||
"warning": "Aviso",
|
||||
@@ -198,8 +200,6 @@
|
||||
"show_all": "Mostrar todo",
|
||||
"cost_duration_chart": "Costes & Duración",
|
||||
"installed": "Instalado",
|
||||
"confirm_delete_object": "¿Eliminar este objeto y todas sus tareas?",
|
||||
"confirm_delete_task": "¿Eliminar esta tarea?",
|
||||
"min": "Mín",
|
||||
"max": "Máx",
|
||||
"save": "Guardar",
|
||||
@@ -241,7 +241,6 @@
|
||||
"sort_task_name": "Nombre de la tarea",
|
||||
"all_objects": "Todos los objetos",
|
||||
"all_parts": "Todas las piezas",
|
||||
"tasks_lower": "tareas",
|
||||
"no_tasks_yet": "Aún no hay tareas",
|
||||
"add_first_task": "Agregar primera tarea",
|
||||
"trigger_configuration": "Configuración del disparador",
|
||||
@@ -298,6 +297,7 @@
|
||||
"tab_calendar": "Calendario",
|
||||
"cal_no_events": "Sin mantenimiento",
|
||||
"cal_every_n_days": "cada {n} días",
|
||||
"cal_every_day": "cada día",
|
||||
"cal_source_time": "Basado en tiempo",
|
||||
"cal_source_time_adaptive": "Basado en tiempo (adaptativo)",
|
||||
"cal_source_sensor": "Basado en sensor",
|
||||
@@ -713,7 +713,6 @@
|
||||
"budget_open_panel": "Abrir en el panel",
|
||||
"groups_empty": "Aún no hay grupos.",
|
||||
"group_new_placeholder": "Añadir grupo…",
|
||||
"group_delete_confirm": "¿Eliminar el grupo \"{name}\"?",
|
||||
"groups_manage_tasks": "Gestionar asignaciones de tareas…",
|
||||
"groups_open_panel": "Abrir en el panel",
|
||||
"on_complete_action_target_hint": "Nota: el dominio de la entidad debe coincidir con el servicio — p. ej. 'button.press' solo funciona en button.*, 'counter.increment' solo en counter.*, 'input_button.press' solo en input_button.* etc. Si no coinciden, la acción falla silenciosamente (HA registra 'Referenced entities ... missing or not currently available').",
|
||||
@@ -764,6 +763,7 @@
|
||||
"doc_delete_confirm": "¿Eliminar «{name}»?",
|
||||
"doc_too_large": "El archivo es demasiado grande (máx. 25 MB).",
|
||||
"doc_upload_failed": "Error al subir.",
|
||||
"docs_archive_too_large": "El archivo comprimido es demasiado grande (máx. {max}).",
|
||||
"completion_photos_optional": "Fotos de finalización (opcional)",
|
||||
"completion_photos": "Fotos de finalización",
|
||||
"choose_photos": "Elegir fotos",
|
||||
@@ -791,7 +791,11 @@
|
||||
"doc_camera": "Tomar foto",
|
||||
"camera_capture_shoot": "Capturar",
|
||||
"camera_switch_lens": "Cambiar de cámara",
|
||||
"camera_switch_failed": "Ninguna otra cámara respondió: este teléfono solo expone una. Usa el selector de archivos para el otro objetivo.",
|
||||
"camera_switching": "Cambiando de cámara…",
|
||||
"camera_not_answered": "Esta cámara no respondió. Toca de nuevo el cambio para probar la siguiente o usa el selector de archivos.",
|
||||
"camera_diagnostics": "Detalles para un informe de error",
|
||||
"camera_copy": "Copiar",
|
||||
"camera_copied": "Copiado",
|
||||
"doc_drop_hint": "Suelta los archivos aquí",
|
||||
"doc_task_none": "Ningún documento vinculado a esta tarea.",
|
||||
"doc_link_existing": "Vincular un documento…",
|
||||
@@ -844,6 +848,55 @@
|
||||
"reading_below_last": "Menor que la última lectura ({value})",
|
||||
"settings_templates_label": "Galería de plantillas",
|
||||
"settings_templates_hint": "Desmarca las plantillas que nunca necesitarás: desaparecen de los selectores \"Desde plantilla\" (panel y flujo de configuración). Nada más cambia; puedes reactivarlas cuando quieras.",
|
||||
"home_profile_title": "Perfil de la vivienda",
|
||||
"home_profile_hint": "Recomienda plantillas y ajusta las ventanas estacionales a tu clima y hemisferio. Todo se calcula localmente a partir de tus plantas, áreas y la ubicación de tu casa.",
|
||||
"home_type_label": "Tipo de vivienda",
|
||||
"home_type_auto": "Automático ({detected})",
|
||||
"home_region_label": "Estado, provincia o región",
|
||||
"home_region_none": "no encontrada",
|
||||
"home_region_hint": "Algunas normas varían dentro de un país: inspección anual del vehículo en un estado, ninguna en el siguiente. Se determina a partir de la ubicación de tu casa.",
|
||||
"home_dwelling_house": "Casa",
|
||||
"home_dwelling_apartment": "Piso",
|
||||
"home_dwelling_unknown": "no reconocido",
|
||||
"home_detected_from": "Detectado por: {reasons}",
|
||||
"home_reason_floors": "varias plantas",
|
||||
"home_reason_basement": "un sótano",
|
||||
"home_reason_garden": "un jardín",
|
||||
"home_reason_shed": "un cobertizo",
|
||||
"home_reason_driveway": "una entrada de coches",
|
||||
"home_reason_garage": "un garaje",
|
||||
"home_reason_attic": "un desván",
|
||||
"home_reason_lawn_mower": "un cortacésped",
|
||||
"home_reason_gate": "una verja",
|
||||
"home_reason_balcony": "un balcón",
|
||||
"home_reason_small_home": "pocas habitaciones en una sola planta",
|
||||
"home_reason_ups": "un SAI",
|
||||
"home_climate_label": "Clima",
|
||||
"home_climate_value": "Köppen {koppen} · mes más frío {cold} °C · mes más cálido {warm} °C",
|
||||
"home_climate_unknown": "No hay ubicación de casa configurada en Home Assistant",
|
||||
"home_hemisphere_south": "Hemisferio sur: las ventanas estacionales se invierten",
|
||||
"home_trait_freeze": "Heladas en invierno",
|
||||
"home_trait_snow": "Inviernos con nieve",
|
||||
"home_trait_severe_winter": "Inviernos rigurosos",
|
||||
"home_trait_damp": "Clima húmedo",
|
||||
"home_trait_mediterranean": "Clima mediterráneo",
|
||||
"home_trait_earthquake": "Zona sísmica",
|
||||
"home_trait_hot_summer": "Veranos calurosos",
|
||||
"home_trait_hot_humid": "Cálido y húmedo",
|
||||
"home_trait_hot_dry": "Cálido y seco",
|
||||
"home_trait_termites": "Zona de termitas",
|
||||
"home_trait_wildfire": "Riesgo de incendios forestales",
|
||||
"home_trait_cyclone": "Zona de huracanes / tifones",
|
||||
"home_trait_tropical": "Clima tropical",
|
||||
"home_trait_radon": "Prueba de radón recomendada",
|
||||
"home_reason_starter": "Lo básico para tu hogar",
|
||||
"home_reason_country": "Habitual en {country}",
|
||||
"home_reason_feature": "Encontrado en tu hogar: {feature}",
|
||||
"templates_recommended_title": "Recomendado para tu hogar",
|
||||
"templates_recommended_hint": "Según tu perfil de vivienda; ajústalo en Ajustes.",
|
||||
"templates_legal_hint": "Las notas sobre obligaciones legales y plazos son orientativas, no asesoramiento jurídico. Las normas varían según la región y cambian con el tiempo; prevalecen la normativa local y las instrucciones del fabricante.",
|
||||
"templates_not_typical": "Poco habitual en tu tipo de vivienda",
|
||||
"templates_set_up": "Ya configurado",
|
||||
"worksheet": "Hoja de trabajo",
|
||||
"worksheet_scan_view": "Escanea para abrir la tarea",
|
||||
"worksheet_scan_complete": "Escanea para completar",
|
||||
@@ -901,6 +954,8 @@
|
||||
"adopt_problem_ok": "ok",
|
||||
"adopt_problem_new_object": "(nuevo)",
|
||||
"adopt_object_hint": "Da a varios sensores el mismo nombre de objeto y se convierten en un solo objeto; escribe el nombre de un objeto existente para añadirlos allí.",
|
||||
"adopt_summary_title": "Qué cambia al adoptar",
|
||||
"adopt_summary_existing": "«{name}»: tareas {before} → {after}",
|
||||
"adopt_problem_adopt": "Adoptar seleccionados",
|
||||
"adopt_problem_done": "{tasks} sensor(es) de problema adoptado(s)",
|
||||
"views_label": "Vistas",
|
||||
@@ -973,6 +1028,15 @@
|
||||
"trend_easing": "Se aleja del umbral",
|
||||
"split_select_hint": "Selecciona una tarea para ver sus detalles aquí",
|
||||
"setups_target_new": "Crear nuevo: {name}",
|
||||
"setups_target_match": "{name} — coincide: {reasons}",
|
||||
"setups_reason_model": "número de modelo",
|
||||
"setups_reason_name": "nombre",
|
||||
"setups_reason_area": "zona",
|
||||
"setups_reason_sibling": "mismo modelo en otro dispositivo",
|
||||
"setups_maybe_covered": "Probablemente ya existe como «{name}»",
|
||||
"setups_already": "Ya existe: {names}",
|
||||
"setups_before_after": "Tareas del objeto: {before} → {after}",
|
||||
"setups_new_object_count": "Objeto nuevo «{name}» · tareas: {count}",
|
||||
"schedule_preview_title": "Próximas fechas",
|
||||
"schedule_preview_ontime": "Suponiendo finalización puntual.",
|
||||
"schedule_preview_ends": "(la serie termina)",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"templates_from": "Mallista",
|
||||
"templates_title": "Aloita mallista",
|
||||
"templates_task_count": "{n} tehtävää",
|
||||
"templates_task_count_one": "1 tehtävä",
|
||||
"template_created": "Luotu mallista",
|
||||
"onboard_hint": "Lisää ensimmäinen kohde ja aloita huollon seuranta.",
|
||||
"edit": "Muokkaa",
|
||||
@@ -46,6 +47,7 @@
|
||||
"bulk_delete_objects_confirm": "Poistetaanko {n} kohdetta kaikkine tehtävineen? Arkistointi säilyttää historian.",
|
||||
"bulk_objects_deleted": "{n} kohdetta poistettu",
|
||||
"bulk_objects_archived": "{n} kohdetta arkistoitu",
|
||||
"bulk_failed": "{n} epäonnistui: {reason}",
|
||||
"completing": "Suoritetaan…",
|
||||
"interval": "Väli",
|
||||
"warning": "Varoitus",
|
||||
@@ -200,8 +202,6 @@
|
||||
"show_all": "Näytä kaikki",
|
||||
"cost_duration_chart": "Kustannus ja kesto",
|
||||
"installed": "Asennettu",
|
||||
"confirm_delete_object": "Poistetaanko tämä kohde ja kaikki sen tehtävät?",
|
||||
"confirm_delete_task": "Poistetaanko tämä tehtävä?",
|
||||
"min": "Min",
|
||||
"max": "Maks",
|
||||
"save": "Tallenna",
|
||||
@@ -243,7 +243,6 @@
|
||||
"sort_task_name": "Tehtävän nimi",
|
||||
"all_objects": "Kaikki kohteet",
|
||||
"all_parts": "Kaikki osat",
|
||||
"tasks_lower": "tehtävää",
|
||||
"no_tasks_yet": "Ei vielä tehtäviä",
|
||||
"add_first_task": "Lisää ensimmäinen tehtävä",
|
||||
"trigger_configuration": "Laukaisimen asetukset",
|
||||
@@ -300,6 +299,7 @@
|
||||
"tab_calendar": "Kalenteri",
|
||||
"cal_no_events": "Ei huoltoja",
|
||||
"cal_every_n_days": "{n} päivän välein",
|
||||
"cal_every_day": "päivittäin",
|
||||
"cal_source_time": "Aikaperusteinen",
|
||||
"cal_source_time_adaptive": "Aikaperusteinen (mukautuva)",
|
||||
"cal_source_sensor": "Anturiperusteinen",
|
||||
@@ -685,7 +685,6 @@
|
||||
"budget_open_panel": "Avaa paneelissa",
|
||||
"groups_empty": "Ei vielä ryhmiä.",
|
||||
"group_new_placeholder": "Lisää ryhmä…",
|
||||
"group_delete_confirm": "Poistetaanko ryhmä \"{name}\"?",
|
||||
"groups_manage_tasks": "Hallinnoi tehtävien määrityksiä…",
|
||||
"groups_open_panel": "Avaa paneelissa",
|
||||
"unassigned": "Ei määritetty",
|
||||
@@ -764,6 +763,7 @@
|
||||
"doc_delete_confirm": "Poistetaanko \"{name}\"?",
|
||||
"doc_too_large": "Tiedosto on liian suuri (enint. 25 Mt).",
|
||||
"doc_upload_failed": "Lataus epäonnistui.",
|
||||
"docs_archive_too_large": "Arkisto on liian suuri (enintään {max}).",
|
||||
"completion_photos_optional": "Valokuvat suorituksesta (valinnainen)",
|
||||
"completion_photos": "Valokuvat suorituksesta",
|
||||
"choose_photos": "Valitse kuvat",
|
||||
@@ -791,7 +791,11 @@
|
||||
"doc_camera": "Ota kuva",
|
||||
"camera_capture_shoot": "Ota kuva",
|
||||
"camera_switch_lens": "Vaihda kameraa",
|
||||
"camera_switch_failed": "Muu kamera ei vastannut – tämä puhelin antaa käyttöön vain yhden. Käytä toiselle objektiiville tiedostovalitsinta.",
|
||||
"camera_switching": "Vaihdetaan kameraa…",
|
||||
"camera_not_answered": "Tämä kamera ei vastannut. Napauta vaihtoa uudelleen kokeillaksesi seuraavaa tai käytä tiedostovalitsinta.",
|
||||
"camera_diagnostics": "Tiedot virheraporttia varten",
|
||||
"camera_copy": "Kopioi",
|
||||
"camera_copied": "Kopioitu",
|
||||
"doc_drop_hint": "Pudota tiedostot tähän",
|
||||
"doc_task_none": "Tähän tehtävään ei ole liitetty asiakirjoja.",
|
||||
"doc_link_existing": "Liitä asiakirja…",
|
||||
@@ -844,6 +848,55 @@
|
||||
"reading_below_last": "Pienempi kuin edellinen lukema ({value})",
|
||||
"settings_templates_label": "Mallipohjagalleria",
|
||||
"settings_templates_hint": "Poista valinta mallipohjista, joita et koskaan tarvitse — ne katoavat \"Mallipohjasta\"-valitsimista (paneeli ja config flow). Mikään muu ei muutu; voi ottaa käyttöön uudelleen milloin tahansa.",
|
||||
"home_profile_title": "Kodin profiili",
|
||||
"home_profile_hint": "Suosittelee mallipohjia ja asettaa kausi-ikkunat ilmastosi ja pallonpuoliskosi mukaan. Kaikki päätellään paikallisesti kerroksistasi, alueistasi ja kodin sijainnista.",
|
||||
"home_type_label": "Asumismuoto",
|
||||
"home_type_auto": "Automaattinen ({detected})",
|
||||
"home_region_label": "Osavaltio, provinssi tai alue",
|
||||
"home_region_none": "ei löytynyt",
|
||||
"home_region_hint": "Jotkin säännöt vaihtelevat maan sisällä – yhdessä osavaltiossa ajoneuvon vuosikatsastus, naapurissa ei mitään. Päätelty kodin sijainnista.",
|
||||
"home_dwelling_house": "Talo",
|
||||
"home_dwelling_apartment": "Kerrostaloasunto",
|
||||
"home_dwelling_unknown": "ei tunnistettu",
|
||||
"home_detected_from": "Tunnistettu näistä: {reasons}",
|
||||
"home_reason_floors": "useita kerroksia",
|
||||
"home_reason_basement": "kellari",
|
||||
"home_reason_garden": "puutarha",
|
||||
"home_reason_shed": "vaja",
|
||||
"home_reason_driveway": "ajotie",
|
||||
"home_reason_garage": "autotalli",
|
||||
"home_reason_attic": "ullakko",
|
||||
"home_reason_lawn_mower": "ruohonleikkuri",
|
||||
"home_reason_gate": "portti",
|
||||
"home_reason_balcony": "parveke",
|
||||
"home_reason_small_home": "vähän huoneita yhdessä kerroksessa",
|
||||
"home_reason_ups": "UPS-laite",
|
||||
"home_climate_label": "Ilmasto",
|
||||
"home_climate_value": "Köppen {koppen} · kylmin kuukausi {cold} °C · lämpimin kuukausi {warm} °C",
|
||||
"home_climate_unknown": "Kodin sijaintia ei ole asetettu Home Assistantissa",
|
||||
"home_hemisphere_south": "Eteläinen pallonpuolisko — kausi-ikkunat käännetään",
|
||||
"home_trait_freeze": "Pakkasta talvella",
|
||||
"home_trait_snow": "Lumiset talvet",
|
||||
"home_trait_severe_winter": "Ankarat talvet",
|
||||
"home_trait_damp": "Kostea ilmasto",
|
||||
"home_trait_mediterranean": "Välimerellinen ilmasto",
|
||||
"home_trait_earthquake": "Maanjäristysalue",
|
||||
"home_trait_hot_summer": "Kuumat kesät",
|
||||
"home_trait_hot_humid": "Kuuma ja kostea",
|
||||
"home_trait_hot_dry": "Kuuma ja kuiva",
|
||||
"home_trait_termites": "Termiittialue",
|
||||
"home_trait_wildfire": "Maastopaloriski",
|
||||
"home_trait_cyclone": "Hurrikaani-/taifuunialue",
|
||||
"home_trait_tropical": "Trooppinen ilmasto",
|
||||
"home_trait_radon": "Radonmittaus suositeltava",
|
||||
"home_reason_starter": "Kodin perusasiat",
|
||||
"home_reason_country": "Yleinen maassa {country}",
|
||||
"home_reason_feature": "Löytyi kodistasi: {feature}",
|
||||
"templates_recommended_title": "Suositellaan kotiisi",
|
||||
"templates_recommended_hint": "Perustuu kodin profiiliin — muuta sitä asetuksissa.",
|
||||
"templates_legal_hint": "Lakisääteisiä velvollisuuksia ja määräaikoja koskevat huomautukset ovat suuntaa-antavia eivätkä ole oikeudellista neuvontaa. Säännöt vaihtelevat alueittain ja muuttuvat — paikalliset määräykset ja valmistajan ohjeet ovat ensisijaisia.",
|
||||
"templates_not_typical": "Harvinainen asumismuodollesi",
|
||||
"templates_set_up": "Jo käytössä",
|
||||
"worksheet": "Työlomake",
|
||||
"worksheet_scan_view": "Skannaa avataksesi tehtävän",
|
||||
"worksheet_scan_complete": "Skannaa suorittaaksesi",
|
||||
@@ -901,6 +954,8 @@
|
||||
"adopt_problem_ok": "ok",
|
||||
"adopt_problem_new_object": "(uusi)",
|
||||
"adopt_object_hint": "Anna useille antureille sama kohteen nimi, niin niistä tulee yksi kohde; kirjoita olemassa olevan kohteen nimi lisätäksesi ne sinne.",
|
||||
"adopt_summary_title": "Mitä käyttöönotto muuttaa",
|
||||
"adopt_summary_existing": "\"{name}\": tehtävät {before} → {after}",
|
||||
"adopt_problem_adopt": "Ota valitut käyttöön",
|
||||
"adopt_problem_done": "Otettiin käyttöön {tasks} ongelma-anturia",
|
||||
"views_label": "Näkymät",
|
||||
@@ -973,6 +1028,15 @@
|
||||
"trend_easing": "Loittonee raja-arvosta",
|
||||
"split_select_hint": "Valitse tehtävä nähdäksesi sen tiedot tässä",
|
||||
"setups_target_new": "Luo uusi: {name}",
|
||||
"setups_target_match": "{name} — vastaa: {reasons}",
|
||||
"setups_reason_model": "mallinumero",
|
||||
"setups_reason_name": "nimi",
|
||||
"setups_reason_area": "alue",
|
||||
"setups_reason_sibling": "sama malli toisessa laitteessa",
|
||||
"setups_maybe_covered": "Todennäköisesti jo olemassa nimellä \"{name}\"",
|
||||
"setups_already": "Jo olemassa: {names}",
|
||||
"setups_before_after": "Kohteen tehtävät: {before} → {after}",
|
||||
"setups_new_object_count": "Uusi kohde \"{name}\" · tehtäviä: {count}",
|
||||
"schedule_preview_title": "Seuraavat päivämäärät",
|
||||
"schedule_preview_ontime": "Olettaen, että valmistuminen on ajallaan.",
|
||||
"schedule_preview_ends": "(sarja päättyy)",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"templates_from": "Depuis un modèle",
|
||||
"templates_title": "Partir d'un modèle",
|
||||
"templates_task_count": "{n} tâches",
|
||||
"templates_task_count_one": "1 tâche",
|
||||
"template_created": "Créé depuis un modèle",
|
||||
"onboard_hint": "Ajoutez votre premier objet pour suivre l'entretien.",
|
||||
"edit": "Modifier",
|
||||
@@ -44,6 +45,7 @@
|
||||
"bulk_delete_objects_confirm": "Supprimer {n} objets et toutes leurs tâches ? L'archivage conserve leur historique.",
|
||||
"bulk_objects_deleted": "{n} objets supprimés",
|
||||
"bulk_objects_archived": "{n} objets archivés",
|
||||
"bulk_failed": "{n} en échec : {reason}",
|
||||
"completing": "En cours…",
|
||||
"interval": "Intervalle",
|
||||
"warning": "Avertissement",
|
||||
@@ -198,8 +200,6 @@
|
||||
"show_all": "Tout afficher",
|
||||
"cost_duration_chart": "Coûts & Durée",
|
||||
"installed": "Installé",
|
||||
"confirm_delete_object": "Supprimer cet objet et toutes ses tâches ?",
|
||||
"confirm_delete_task": "Supprimer cette tâche ?",
|
||||
"min": "Min",
|
||||
"max": "Max",
|
||||
"save": "Enregistrer",
|
||||
@@ -241,7 +241,6 @@
|
||||
"sort_task_name": "Nom de la tâche",
|
||||
"all_objects": "Tous les objets",
|
||||
"all_parts": "Toutes les pièces",
|
||||
"tasks_lower": "tâches",
|
||||
"no_tasks_yet": "Pas encore de tâches",
|
||||
"add_first_task": "Ajouter la première tâche",
|
||||
"trigger_configuration": "Configuration du déclencheur",
|
||||
@@ -298,6 +297,7 @@
|
||||
"tab_calendar": "Calendrier",
|
||||
"cal_no_events": "Aucun entretien",
|
||||
"cal_every_n_days": "tous les {n} jours",
|
||||
"cal_every_day": "tous les jours",
|
||||
"cal_source_time": "Basé sur le temps",
|
||||
"cal_source_time_adaptive": "Basé sur le temps (adaptatif)",
|
||||
"cal_source_sensor": "Basé sur capteur",
|
||||
@@ -713,7 +713,6 @@
|
||||
"budget_open_panel": "Ouvrir dans le panneau",
|
||||
"groups_empty": "Aucun groupe pour le moment.",
|
||||
"group_new_placeholder": "Ajouter un groupe…",
|
||||
"group_delete_confirm": "Supprimer le groupe « {name} » ?",
|
||||
"groups_manage_tasks": "Gérer les affectations de tâches…",
|
||||
"groups_open_panel": "Ouvrir dans le panneau",
|
||||
"on_complete_action_target_hint": "Note : le domaine de l'entité doit correspondre au service — p. ex. 'button.press' ne fonctionne que sur button.*, 'counter.increment' seulement sur counter.*, 'input_button.press' seulement sur input_button.* etc. En cas de non-correspondance, l'action échoue silencieusement (HA journalise 'Referenced entities ... missing or not currently available').",
|
||||
@@ -764,6 +763,7 @@
|
||||
"doc_delete_confirm": "Supprimer « {name} » ?",
|
||||
"doc_too_large": "Fichier trop volumineux (max. 25 Mo).",
|
||||
"doc_upload_failed": "Échec du téléversement.",
|
||||
"docs_archive_too_large": "Archive trop volumineuse (max. {max}).",
|
||||
"completion_photos_optional": "Photos de fin de tâche (facultatif)",
|
||||
"completion_photos": "Photos de fin de tâche",
|
||||
"choose_photos": "Choisir des photos",
|
||||
@@ -791,7 +791,11 @@
|
||||
"doc_camera": "Prendre une photo",
|
||||
"camera_capture_shoot": "Prendre la photo",
|
||||
"camera_switch_lens": "Changer de caméra",
|
||||
"camera_switch_failed": "Aucune autre caméra n'a répondu — ce téléphone n'en expose qu'une. Utilisez le sélecteur de fichiers pour l'autre objectif.",
|
||||
"camera_switching": "Changement de caméra…",
|
||||
"camera_not_answered": "Cette caméra n'a pas répondu. Touchez à nouveau le bouton de changement pour essayer la suivante, ou utilisez le sélecteur de fichiers.",
|
||||
"camera_diagnostics": "Détails pour un rapport de bug",
|
||||
"camera_copy": "Copier",
|
||||
"camera_copied": "Copié",
|
||||
"doc_drop_hint": "Déposez les fichiers ici",
|
||||
"doc_task_none": "Aucun document lié à cette tâche.",
|
||||
"doc_link_existing": "Lier un document…",
|
||||
@@ -844,6 +848,55 @@
|
||||
"reading_below_last": "Inférieur au dernier relevé ({value})",
|
||||
"settings_templates_label": "Galerie de modèles",
|
||||
"settings_templates_hint": "Décochez les modèles dont vous n'aurez jamais besoin — ils disparaissent des sélecteurs « Depuis un modèle » (panneau et config flow). Rien d'autre ne change ; réactivables à tout moment.",
|
||||
"home_profile_title": "Profil du logement",
|
||||
"home_profile_hint": "Recommande des modèles et règle les périodes saisonnières selon votre climat et votre hémisphère. Tout est déterminé localement à partir de vos étages, pièces et de l'emplacement du domicile.",
|
||||
"home_type_label": "Type de logement",
|
||||
"home_type_auto": "Automatique ({detected})",
|
||||
"home_region_label": "État, province ou région",
|
||||
"home_region_none": "introuvable",
|
||||
"home_region_hint": "Certaines règles varient au sein d'un pays : contrôle annuel des véhicules dans un État, aucun dans le suivant. Déterminé à partir de l'emplacement de votre domicile.",
|
||||
"home_dwelling_house": "Maison",
|
||||
"home_dwelling_apartment": "Appartement",
|
||||
"home_dwelling_unknown": "non reconnu",
|
||||
"home_detected_from": "Détecté grâce à : {reasons}",
|
||||
"home_reason_floors": "plusieurs étages",
|
||||
"home_reason_basement": "un sous-sol",
|
||||
"home_reason_garden": "un jardin",
|
||||
"home_reason_shed": "un abri de jardin",
|
||||
"home_reason_driveway": "une allée",
|
||||
"home_reason_garage": "un garage",
|
||||
"home_reason_attic": "un grenier",
|
||||
"home_reason_lawn_mower": "une tondeuse",
|
||||
"home_reason_gate": "un portail",
|
||||
"home_reason_balcony": "un balcon",
|
||||
"home_reason_small_home": "peu de pièces sur un seul étage",
|
||||
"home_reason_ups": "un onduleur",
|
||||
"home_climate_label": "Climat",
|
||||
"home_climate_value": "Köppen {koppen} · mois le plus froid {cold} °C · mois le plus chaud {warm} °C",
|
||||
"home_climate_unknown": "Aucun emplacement du domicile défini dans Home Assistant",
|
||||
"home_hemisphere_south": "Hémisphère sud — les périodes saisonnières sont inversées",
|
||||
"home_trait_freeze": "Gel en hiver",
|
||||
"home_trait_snow": "Hivers enneigés",
|
||||
"home_trait_severe_winter": "Hivers rigoureux",
|
||||
"home_trait_damp": "Climat humide",
|
||||
"home_trait_mediterranean": "Climat méditerranéen",
|
||||
"home_trait_earthquake": "Zone sismique",
|
||||
"home_trait_hot_summer": "Étés chauds",
|
||||
"home_trait_hot_humid": "Chaud et humide",
|
||||
"home_trait_hot_dry": "Chaud et sec",
|
||||
"home_trait_termites": "Zone à termites",
|
||||
"home_trait_wildfire": "Risque de feux de forêt",
|
||||
"home_trait_cyclone": "Zone d'ouragans / de typhons",
|
||||
"home_trait_tropical": "Climat tropical",
|
||||
"home_trait_radon": "Test du radon recommandé",
|
||||
"home_reason_starter": "Les bases pour votre logement",
|
||||
"home_reason_country": "Courant en/au {country}",
|
||||
"home_reason_feature": "Présent dans votre logement : {feature}",
|
||||
"templates_recommended_title": "Recommandé pour votre logement",
|
||||
"templates_recommended_hint": "D'après votre profil de logement — modifiable dans les Paramètres.",
|
||||
"templates_legal_hint": "Les indications sur les obligations légales et les échéances sont données à titre indicatif et ne constituent pas un conseil juridique. Les règles varient selon les régions et évoluent — la réglementation locale et les instructions du fabricant prévalent.",
|
||||
"templates_not_typical": "Peu courant pour votre type de logement",
|
||||
"templates_set_up": "Déjà configuré",
|
||||
"worksheet": "Fiche de travail",
|
||||
"worksheet_scan_view": "Scanner pour ouvrir la tâche",
|
||||
"worksheet_scan_complete": "Scanner pour terminer",
|
||||
@@ -901,6 +954,8 @@
|
||||
"adopt_problem_ok": "ok",
|
||||
"adopt_problem_new_object": "(nouveau)",
|
||||
"adopt_object_hint": "Donnez le même nom d'objet à plusieurs capteurs et ils forment un seul objet ; saisissez le nom d'un objet existant pour les y ajouter.",
|
||||
"adopt_summary_title": "Ce que l'adoption modifie",
|
||||
"adopt_summary_existing": "« {name} » : tâches {before} → {after}",
|
||||
"adopt_problem_adopt": "Adopter la sélection",
|
||||
"adopt_problem_done": "{tasks} capteur(s) de problème adopté(s)",
|
||||
"views_label": "Vues",
|
||||
@@ -973,6 +1028,15 @@
|
||||
"trend_easing": "S'éloigne du seuil",
|
||||
"split_select_hint": "Sélectionnez une tâche pour voir ses détails ici",
|
||||
"setups_target_new": "Créer : {name}",
|
||||
"setups_target_match": "{name} — correspond : {reasons}",
|
||||
"setups_reason_model": "numéro de modèle",
|
||||
"setups_reason_name": "nom",
|
||||
"setups_reason_area": "pièce",
|
||||
"setups_reason_sibling": "même modèle sur un autre appareil",
|
||||
"setups_maybe_covered": "Existe probablement déjà sous « {name} »",
|
||||
"setups_already": "Déjà présent : {names}",
|
||||
"setups_before_after": "Tâches de l'objet : {before} → {after}",
|
||||
"setups_new_object_count": "Nouvel objet « {name} » · tâches : {count}",
|
||||
"schedule_preview_title": "Prochaines échéances",
|
||||
"schedule_preview_ontime": "En supposant un achèvement ponctuel.",
|
||||
"schedule_preview_ends": "(fin de la série)",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"templates_from": "टेम्पलेट से",
|
||||
"templates_title": "टेम्पलेट से शुरू करें",
|
||||
"templates_task_count": "{n} कार्य",
|
||||
"templates_task_count_one": "1 कार्य",
|
||||
"template_created": "टेम्पलेट से बनाया गया",
|
||||
"onboard_hint": "रखरखाव ट्रैक करने के लिए अपना पहला ऑब्जेक्ट जोड़ें।",
|
||||
"edit": "संपादित करें",
|
||||
@@ -46,6 +47,7 @@
|
||||
"bulk_delete_objects_confirm": "{n} ऑब्जेक्ट और उनके सभी कार्य हटाएँ? संग्रहित करने से उनका इतिहास बना रहता है।",
|
||||
"bulk_objects_deleted": "{n} ऑब्जेक्ट हटाए गए",
|
||||
"bulk_objects_archived": "{n} ऑब्जेक्ट संग्रहित किए गए",
|
||||
"bulk_failed": "{n} विफल: {reason}",
|
||||
"completing": "पूर्ण किया जा रहा है…",
|
||||
"interval": "अंतराल",
|
||||
"warning": "चेतावनी",
|
||||
@@ -200,8 +202,6 @@
|
||||
"show_all": "सभी दिखाएँ",
|
||||
"cost_duration_chart": "लागत और अवधि",
|
||||
"installed": "स्थापित",
|
||||
"confirm_delete_object": "इस वस्तु और इसके सभी कार्यों को हटाएँ?",
|
||||
"confirm_delete_task": "इस कार्य को हटाएँ?",
|
||||
"min": "न्यूनतम",
|
||||
"max": "अधिकतम",
|
||||
"save": "सहेजें",
|
||||
@@ -243,7 +243,6 @@
|
||||
"sort_task_name": "कार्य का नाम",
|
||||
"all_objects": "सभी वस्तुएँ",
|
||||
"all_parts": "सभी पुर्ज़े",
|
||||
"tasks_lower": "कार्य",
|
||||
"no_tasks_yet": "अभी कोई कार्य नहीं",
|
||||
"add_first_task": "पहला कार्य जोड़ें",
|
||||
"trigger_configuration": "ट्रिगर कॉन्फ़िगरेशन",
|
||||
@@ -300,6 +299,7 @@
|
||||
"tab_calendar": "कैलेंडर",
|
||||
"cal_no_events": "कोई रखरखाव नहीं",
|
||||
"cal_every_n_days": "हर {n} दिन",
|
||||
"cal_every_day": "हर दिन",
|
||||
"cal_source_time": "समय-आधारित",
|
||||
"cal_source_time_adaptive": "समय-आधारित (अनुकूली)",
|
||||
"cal_source_sensor": "सेंसर-आधारित",
|
||||
@@ -685,7 +685,6 @@
|
||||
"budget_open_panel": "पैनल में खोलें",
|
||||
"groups_empty": "अभी कोई समूह नहीं।",
|
||||
"group_new_placeholder": "समूह जोड़ें…",
|
||||
"group_delete_confirm": "समूह \"{name}\" हटाएँ?",
|
||||
"groups_manage_tasks": "कार्य असाइनमेंट प्रबंधित करें…",
|
||||
"groups_open_panel": "पैनल में खोलें",
|
||||
"unassigned": "असाइन नहीं",
|
||||
@@ -764,6 +763,7 @@
|
||||
"doc_delete_confirm": "\"{name}\" हटाएँ?",
|
||||
"doc_too_large": "फ़ाइल बहुत बड़ी है (अधिकतम 25 MB)।",
|
||||
"doc_upload_failed": "अपलोड विफल रहा।",
|
||||
"docs_archive_too_large": "आर्काइव बहुत बड़ा है (अधिकतम {max})।",
|
||||
"completion_photos_optional": "पूर्णता की तस्वीरें (वैकल्पिक)",
|
||||
"completion_photos": "पूर्णता की तस्वीरें",
|
||||
"choose_photos": "तस्वीरें चुनें",
|
||||
@@ -791,7 +791,11 @@
|
||||
"doc_camera": "फ़ोटो लें",
|
||||
"camera_capture_shoot": "कैप्चर करें",
|
||||
"camera_switch_lens": "कैमरा बदलें",
|
||||
"camera_switch_failed": "किसी अन्य कैमरे ने जवाब नहीं दिया — यह फ़ोन केवल एक ही देता है। दूसरे लेंस के लिए फ़ाइल चयनकर्ता का उपयोग करें।",
|
||||
"camera_switching": "कैमरा बदला जा रहा है…",
|
||||
"camera_not_answered": "इस कैमरे ने जवाब नहीं दिया। अगला आज़माने के लिए फिर से बदलें पर टैप करें, या फ़ाइल पिकर का उपयोग करें।",
|
||||
"camera_diagnostics": "बग रिपोर्ट के लिए विवरण",
|
||||
"camera_copy": "कॉपी करें",
|
||||
"camera_copied": "कॉपी हो गया",
|
||||
"doc_drop_hint": "फ़ाइलें यहाँ छोड़ें",
|
||||
"doc_task_none": "इस कार्य से कोई दस्तावेज़ संबद्ध नहीं है।",
|
||||
"doc_link_existing": "दस्तावेज़ जोड़ें…",
|
||||
@@ -844,6 +848,55 @@
|
||||
"reading_below_last": "पिछली रीडिंग ({value}) से कम",
|
||||
"settings_templates_label": "टेम्पलेट गैलरी",
|
||||
"settings_templates_hint": "जिन टेम्पलेट की कभी ज़रूरत नहीं होगी उन्हें अनचेक करें — वे \"टेम्पलेट से\" चयनकर्ताओं (पैनल और कॉन्फ़िग फ़्लो) से गायब हो जाएंगे। और कुछ नहीं बदलता; कभी भी फिर से सक्षम कर सकते हैं।",
|
||||
"home_profile_title": "घर की प्रोफ़ाइल",
|
||||
"home_profile_hint": "आपकी जलवायु और गोलार्ध के अनुसार टेम्पलेट सुझाता है और मौसमी विंडो सेट करता है। सब कुछ आपकी मंज़िलों, क्षेत्रों और घर के स्थान से स्थानीय रूप से तय होता है।",
|
||||
"home_type_label": "घर का प्रकार",
|
||||
"home_type_auto": "स्वचालित ({detected})",
|
||||
"home_region_label": "राज्य, प्रांत या क्षेत्र",
|
||||
"home_region_none": "नहीं मिला",
|
||||
"home_region_hint": "कुछ नियम एक ही देश में अलग-अलग होते हैं — एक राज्य में वाहन की वार्षिक जाँच, अगले में कोई नहीं। आपके घर के स्थान से पता लगाया गया।",
|
||||
"home_dwelling_house": "मकान",
|
||||
"home_dwelling_apartment": "अपार्टमेंट",
|
||||
"home_dwelling_unknown": "पहचाना नहीं गया",
|
||||
"home_detected_from": "इनसे पहचाना गया: {reasons}",
|
||||
"home_reason_floors": "कई मंज़िलें",
|
||||
"home_reason_basement": "तहखाना",
|
||||
"home_reason_garden": "बगीचा",
|
||||
"home_reason_shed": "शेड",
|
||||
"home_reason_driveway": "ड्राइववे",
|
||||
"home_reason_garage": "गैराज",
|
||||
"home_reason_attic": "अटारी",
|
||||
"home_reason_lawn_mower": "लॉन मोवर",
|
||||
"home_reason_gate": "गेट",
|
||||
"home_reason_balcony": "बालकनी",
|
||||
"home_reason_small_home": "एक मंज़िल पर कम कमरे",
|
||||
"home_reason_ups": "यूपीएस (अबाधित बिजली आपूर्ति)",
|
||||
"home_climate_label": "जलवायु",
|
||||
"home_climate_value": "कोपेन {koppen} · सबसे ठंडा महीना {cold} °C · सबसे गर्म महीना {warm} °C",
|
||||
"home_climate_unknown": "Home Assistant में घर का स्थान सेट नहीं है",
|
||||
"home_hemisphere_south": "दक्षिणी गोलार्ध — मौसमी विंडो उलट दी जाती हैं",
|
||||
"home_trait_freeze": "सर्दियों में पाला",
|
||||
"home_trait_snow": "बर्फ़ीली सर्दियाँ",
|
||||
"home_trait_severe_winter": "कड़ाके की सर्दियाँ",
|
||||
"home_trait_damp": "नम जलवायु",
|
||||
"home_trait_mediterranean": "भूमध्यसागरीय जलवायु",
|
||||
"home_trait_earthquake": "भूकंप संभावित क्षेत्र",
|
||||
"home_trait_hot_summer": "गर्म गर्मियाँ",
|
||||
"home_trait_hot_humid": "गर्म और आर्द्र",
|
||||
"home_trait_hot_dry": "गर्म और शुष्क",
|
||||
"home_trait_termites": "दीमक प्रभावित क्षेत्र",
|
||||
"home_trait_wildfire": "जंगल की आग का खतरा",
|
||||
"home_trait_cyclone": "तूफ़ान / टाइफ़ून क्षेत्र",
|
||||
"home_trait_tropical": "उष्णकटिबंधीय जलवायु",
|
||||
"home_trait_radon": "रेडॉन जाँच की सलाह",
|
||||
"home_reason_starter": "आपके घर की बुनियादी ज़रूरतें",
|
||||
"home_reason_country": "{country} में आम",
|
||||
"home_reason_feature": "आपके घर में मिला: {feature}",
|
||||
"templates_recommended_title": "आपके घर के लिए सुझाए गए",
|
||||
"templates_recommended_hint": "आपकी घर की प्रोफ़ाइल पर आधारित — सेटिंग्स में बदलें।",
|
||||
"templates_legal_hint": "कानूनी दायित्वों और समय-सीमाओं से जुड़े नोट केवल मार्गदर्शन हैं, कानूनी सलाह नहीं। नियम क्षेत्र के अनुसार अलग होते हैं और बदलते रहते हैं — स्थानीय नियम और निर्माता के निर्देश ही मान्य हैं।",
|
||||
"templates_not_typical": "आपके घर के प्रकार के लिए असामान्य",
|
||||
"templates_set_up": "पहले से सेट है",
|
||||
"worksheet": "कार्य पत्रक",
|
||||
"worksheet_scan_view": "कार्य खोलने के लिए स्कैन करें",
|
||||
"worksheet_scan_complete": "पूरा करने के लिए स्कैन करें",
|
||||
@@ -901,6 +954,8 @@
|
||||
"adopt_problem_ok": "ठीक",
|
||||
"adopt_problem_new_object": "(नया)",
|
||||
"adopt_object_hint": "कई सेंसरों को एक ही ऑब्जेक्ट नाम दें तो वे एक ऑब्जेक्ट बन जाते हैं; मौजूदा ऑब्जेक्ट का नाम लिखें तो वे वहीं जुड़ जाते हैं।",
|
||||
"adopt_summary_title": "अपनाने से क्या बदलेगा",
|
||||
"adopt_summary_existing": "\"{name}\": कार्य {before} → {after}",
|
||||
"adopt_problem_adopt": "चयनित अपनाएँ",
|
||||
"adopt_problem_done": "{tasks} समस्या सेंसर अपनाए गए",
|
||||
"views_label": "व्यू",
|
||||
@@ -973,6 +1028,15 @@
|
||||
"trend_easing": "सीमा से दूर जा रहा है",
|
||||
"split_select_hint": "विवरण यहाँ देखने के लिए कोई कार्य चुनें",
|
||||
"setups_target_new": "नया बनाएँ: {name}",
|
||||
"setups_target_match": "{name} — मेल: {reasons}",
|
||||
"setups_reason_model": "मॉडल नंबर",
|
||||
"setups_reason_name": "नाम",
|
||||
"setups_reason_area": "क्षेत्र",
|
||||
"setups_reason_sibling": "दूसरे डिवाइस पर वही मॉडल",
|
||||
"setups_maybe_covered": "शायद पहले से \"{name}\" के रूप में मौजूद है",
|
||||
"setups_already": "पहले से मौजूद: {names}",
|
||||
"setups_before_after": "ऑब्जेक्ट के कार्य: {before} → {after}",
|
||||
"setups_new_object_count": "नया ऑब्जेक्ट \"{name}\" · कार्य: {count}",
|
||||
"schedule_preview_title": "अगली तिथियाँ",
|
||||
"schedule_preview_ontime": "समय पर पूर्णता मानते हुए।",
|
||||
"schedule_preview_ends": "(श्रृंखला समाप्त)",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"templates_from": "Sablonból",
|
||||
"templates_title": "Kezdés sablonból",
|
||||
"templates_task_count": "{n} feladat",
|
||||
"templates_task_count_one": "1 feladat",
|
||||
"template_created": "Létrehozva sablonból",
|
||||
"onboard_hint": "Adja hozzá az első objektumot a karbantartások nyomon követéséhez.",
|
||||
"edit": "Szerkesztés",
|
||||
@@ -46,6 +47,7 @@
|
||||
"bulk_delete_objects_confirm": "Törli a(z) {n} objektumot az összes feladatával együtt? Az archiválás ehelyett megőrzi az előzményeket.",
|
||||
"bulk_objects_deleted": "{n} objektum törölve",
|
||||
"bulk_objects_archived": "{n} objektum archiválva",
|
||||
"bulk_failed": "{n} sikertelen: {reason}",
|
||||
"completing": "Elvégzés…",
|
||||
"interval": "Intervallum",
|
||||
"warning": "Figyelmeztetés",
|
||||
@@ -200,8 +202,6 @@
|
||||
"show_all": "Összes megjelenítése",
|
||||
"cost_duration_chart": "Költség és időtartam",
|
||||
"installed": "Telepítve",
|
||||
"confirm_delete_object": "Törli az objektumot az összes feladatával együtt?",
|
||||
"confirm_delete_task": "Törli ezt a feladatot?",
|
||||
"min": "Min",
|
||||
"max": "Max",
|
||||
"save": "Mentés",
|
||||
@@ -243,7 +243,6 @@
|
||||
"sort_task_name": "Feladat neve",
|
||||
"all_objects": "Minden objektum",
|
||||
"all_parts": "Összes alkatrész",
|
||||
"tasks_lower": "feladat",
|
||||
"no_tasks_yet": "Még nincs feladat",
|
||||
"add_first_task": "Első feladat hozzáadása",
|
||||
"trigger_configuration": "Kiváltó beállítása",
|
||||
@@ -300,6 +299,7 @@
|
||||
"tab_calendar": "Naptár",
|
||||
"cal_no_events": "Nincs karbantartás",
|
||||
"cal_every_n_days": "{n} naponta",
|
||||
"cal_every_day": "naponta",
|
||||
"cal_source_time": "Időalapú",
|
||||
"cal_source_time_adaptive": "Időalapú (adaptív)",
|
||||
"cal_source_sensor": "Érzékelőalapú",
|
||||
@@ -691,7 +691,6 @@
|
||||
"budget_open_panel": "Megnyitás a panelen",
|
||||
"groups_empty": "Még nincsenek csoportok.",
|
||||
"group_new_placeholder": "Csoport hozzáadása…",
|
||||
"group_delete_confirm": "Törli a(z) „{name}” csoportot?",
|
||||
"groups_manage_tasks": "Feladat-hozzárendelések kezelése…",
|
||||
"groups_open_panel": "Megnyitás a panelen",
|
||||
"unassigned": "Nincs hozzárendelve",
|
||||
@@ -770,6 +769,7 @@
|
||||
"doc_delete_confirm": "Törli a(z) „{name}” elemet?",
|
||||
"doc_too_large": "A fájl túl nagy (max. 25 MB).",
|
||||
"doc_upload_failed": "A feltöltés nem sikerült.",
|
||||
"docs_archive_too_large": "Az archívum túl nagy (max. {max}).",
|
||||
"completion_photos_optional": "Fotók az elvégzésről (nem kötelező)",
|
||||
"completion_photos": "Fotók az elvégzésről",
|
||||
"choose_photos": "Fotók kiválasztása",
|
||||
@@ -797,7 +797,11 @@
|
||||
"doc_camera": "Fotó készítése",
|
||||
"camera_capture_shoot": "Felvétel",
|
||||
"camera_switch_lens": "Kamera váltása",
|
||||
"camera_switch_failed": "Nem válaszolt másik kamera – ez a telefon csak egyet ad ki. A másik objektívhez használja a fájlválasztót.",
|
||||
"camera_switching": "Kamera váltása…",
|
||||
"camera_not_answered": "Ez a kamera nem válaszolt. Koppintson újra a váltásra a következő kipróbálásához, vagy használja a fájlválasztót.",
|
||||
"camera_diagnostics": "Részletek hibajelentéshez",
|
||||
"camera_copy": "Másolás",
|
||||
"camera_copied": "Másolva",
|
||||
"doc_drop_hint": "Húzza ide a fájlokat",
|
||||
"doc_task_none": "Ehhez a feladathoz nincs dokumentum csatolva.",
|
||||
"doc_link_existing": "Dokumentum hozzárendelése…",
|
||||
@@ -850,6 +854,55 @@
|
||||
"reading_below_last": "Kisebb az előző leolvasásnál ({value})",
|
||||
"settings_templates_label": "Sablongaléria",
|
||||
"settings_templates_hint": "Vegye ki a pipát a sosem használt sablonoknál — így eltűnnek a „Sablonból” választókból (panel és konfigurációs folyamat). Semmi más nem változik; bármikor újra engedélyezheti őket.",
|
||||
"home_profile_title": "Otthon profilja",
|
||||
"home_profile_hint": "Sablonokat javasol, és az éghajlathoz és a féltekéhez igazítja a szezonális időablakokat. Minden helyben, az Ön szintjeiből, területeiből és az otthon helyéből adódik.",
|
||||
"home_type_label": "Lakóhely típusa",
|
||||
"home_type_auto": "Automatikus ({detected})",
|
||||
"home_region_label": "Állam, tartomány vagy régió",
|
||||
"home_region_none": "nem található",
|
||||
"home_region_hint": "Egyes szabályok országon belül is eltérnek – az egyik államban évente kötelező a járművizsga, a szomszédosban egyáltalán nem. Az otthon helyéből megállapítva.",
|
||||
"home_dwelling_house": "Ház",
|
||||
"home_dwelling_apartment": "Lakás",
|
||||
"home_dwelling_unknown": "nem felismerhető",
|
||||
"home_detected_from": "Felismerés alapja: {reasons}",
|
||||
"home_reason_floors": "több szint",
|
||||
"home_reason_basement": "pince",
|
||||
"home_reason_garden": "kert",
|
||||
"home_reason_shed": "fészer",
|
||||
"home_reason_driveway": "felhajtó",
|
||||
"home_reason_garage": "garázs",
|
||||
"home_reason_attic": "padlás",
|
||||
"home_reason_lawn_mower": "fűnyíró",
|
||||
"home_reason_gate": "kapu",
|
||||
"home_reason_balcony": "erkély",
|
||||
"home_reason_small_home": "kevés helyiség egy szinten",
|
||||
"home_reason_ups": "szünetmentes táp",
|
||||
"home_climate_label": "Éghajlat",
|
||||
"home_climate_value": "Köppen {koppen} · leghidegebb hónap {cold} °C · legmelegebb hónap {warm} °C",
|
||||
"home_climate_unknown": "Nincs otthoni hely beállítva a Home Assistantban",
|
||||
"home_hemisphere_south": "Déli félteke — a szezonális időablakok tükröződnek",
|
||||
"home_trait_freeze": "Téli fagy",
|
||||
"home_trait_snow": "Havas telek",
|
||||
"home_trait_severe_winter": "Kemény telek",
|
||||
"home_trait_damp": "Nedves éghajlat",
|
||||
"home_trait_mediterranean": "Mediterrán éghajlat",
|
||||
"home_trait_earthquake": "Földrengésveszélyes terület",
|
||||
"home_trait_hot_summer": "Forró nyarak",
|
||||
"home_trait_hot_humid": "Forró és párás",
|
||||
"home_trait_hot_dry": "Forró és száraz",
|
||||
"home_trait_termites": "Termeszveszélyes terület",
|
||||
"home_trait_wildfire": "Erdőtűzveszély",
|
||||
"home_trait_cyclone": "Hurrikán-/tájfunövezet",
|
||||
"home_trait_tropical": "Trópusi éghajlat",
|
||||
"home_trait_radon": "Radonmérés ajánlott",
|
||||
"home_reason_starter": "Az otthon alapjai",
|
||||
"home_reason_country": "Gyakori itt: {country}",
|
||||
"home_reason_feature": "Az otthonában található: {feature}",
|
||||
"templates_recommended_title": "Ajánlott az otthonához",
|
||||
"templates_recommended_hint": "Az otthon profilja alapján — a Beállításokban módosíthatja.",
|
||||
"templates_legal_hint": "A jogi kötelezettségekre és határidőkre vonatkozó megjegyzések tájékoztató jellegűek, nem minősülnek jogi tanácsnak. A szabályok régiónként eltérnek és változnak — a helyi előírások és a gyártó utasításai az irányadók.",
|
||||
"templates_not_typical": "Az Ön lakóhelytípusánál nem jellemző",
|
||||
"templates_set_up": "Már beállítva",
|
||||
"worksheet": "Munkalap",
|
||||
"worksheet_scan_view": "Olvassa be a feladat megnyitásához",
|
||||
"worksheet_scan_complete": "Olvassa be az elvégzéshez",
|
||||
@@ -901,6 +954,8 @@
|
||||
"adopt_problem_ok": "ok",
|
||||
"adopt_problem_new_object": "(új)",
|
||||
"adopt_object_hint": "Adja több érzékelőnek ugyanazt az objektumnevet, és egy objektummá válnak; meglévő objektum nevét beírva oda kerülnek.",
|
||||
"adopt_summary_title": "Mit változtat az átvétel",
|
||||
"adopt_summary_existing": "„{name}”: feladatok {before} → {after}",
|
||||
"adopt_problem_adopt": "Kijelöltek átvétele",
|
||||
"adopt_problem_done": "{tasks} problémaérzékelő átvéve",
|
||||
"views_label": "Nézetek",
|
||||
@@ -973,6 +1028,15 @@
|
||||
"trend_easing": "Távolodik a küszöbtől",
|
||||
"split_select_hint": "Válassz egy feladatot, és itt jelennek meg a részletei",
|
||||
"setups_target_new": "Új létrehozása: {name}",
|
||||
"setups_target_match": "{name} — egyezik: {reasons}",
|
||||
"setups_reason_model": "típusszám",
|
||||
"setups_reason_name": "név",
|
||||
"setups_reason_area": "terület",
|
||||
"setups_reason_sibling": "ugyanaz a típus egy másik eszközön",
|
||||
"setups_maybe_covered": "Valószínűleg már megvan „{name}” néven",
|
||||
"setups_already": "Már megvan: {names}",
|
||||
"setups_before_after": "Az objektum feladatai: {before} → {after}",
|
||||
"setups_new_object_count": "Új objektum „{name}” · feladatok: {count}",
|
||||
"schedule_preview_title": "Következő dátumok",
|
||||
"schedule_preview_ontime": "Időben történő elvégzést feltételezve.",
|
||||
"schedule_preview_ends": "(a sorozat véget ér)",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"templates_from": "Da modello",
|
||||
"templates_title": "Inizia da un modello",
|
||||
"templates_task_count": "{n} attività",
|
||||
"templates_task_count_one": "1 attività",
|
||||
"template_created": "Creato da modello",
|
||||
"onboard_hint": "Aggiungi il primo oggetto per iniziare a tracciare la manutenzione.",
|
||||
"edit": "Modifica",
|
||||
@@ -44,6 +45,7 @@
|
||||
"bulk_delete_objects_confirm": "Eliminare {n} oggetti con tutte le loro attività? L'archiviazione conserva invece la cronologia.",
|
||||
"bulk_objects_deleted": "{n} oggetti eliminati",
|
||||
"bulk_objects_archived": "{n} oggetti archiviati",
|
||||
"bulk_failed": "{n} non riusciti: {reason}",
|
||||
"completing": "Completamento…",
|
||||
"interval": "Intervallo",
|
||||
"warning": "Avviso",
|
||||
@@ -198,8 +200,6 @@
|
||||
"show_all": "Mostra tutto",
|
||||
"cost_duration_chart": "Costi & Durata",
|
||||
"installed": "Installato",
|
||||
"confirm_delete_object": "Eliminare questo oggetto e tutte le sue attività?",
|
||||
"confirm_delete_task": "Eliminare questa attività?",
|
||||
"min": "Min",
|
||||
"max": "Max",
|
||||
"save": "Salva",
|
||||
@@ -241,7 +241,6 @@
|
||||
"sort_task_name": "Nome attività",
|
||||
"all_objects": "Tutti gli oggetti",
|
||||
"all_parts": "Tutti i ricambi",
|
||||
"tasks_lower": "attività",
|
||||
"no_tasks_yet": "Nessuna attività",
|
||||
"add_first_task": "Aggiungi prima attività",
|
||||
"trigger_configuration": "Configurazione trigger",
|
||||
@@ -298,6 +297,7 @@
|
||||
"tab_calendar": "Calendario",
|
||||
"cal_no_events": "Nessuna manutenzione",
|
||||
"cal_every_n_days": "ogni {n} giorni",
|
||||
"cal_every_day": "ogni giorno",
|
||||
"cal_source_time": "Basato sul tempo",
|
||||
"cal_source_time_adaptive": "Basato sul tempo (adattivo)",
|
||||
"cal_source_sensor": "Basato su sensore",
|
||||
@@ -713,7 +713,6 @@
|
||||
"budget_open_panel": "Apri nel pannello",
|
||||
"groups_empty": "Nessun gruppo ancora.",
|
||||
"group_new_placeholder": "Aggiungi gruppo…",
|
||||
"group_delete_confirm": "Eliminare il gruppo \"{name}\"?",
|
||||
"groups_manage_tasks": "Gestisci assegnazioni attività…",
|
||||
"groups_open_panel": "Apri nel pannello",
|
||||
"on_complete_action_target_hint": "Nota: il dominio dell'entità deve corrispondere al servizio — es. 'button.press' funziona solo su button.*, 'counter.increment' solo su counter.*, 'input_button.press' solo su input_button.* ecc. In caso di mancata corrispondenza l'azione fallisce silenziosamente (HA registra 'Referenced entities ... missing or not currently available').",
|
||||
@@ -764,6 +763,7 @@
|
||||
"doc_delete_confirm": "Eliminare «{name}»?",
|
||||
"doc_too_large": "File troppo grande (max 25 MB).",
|
||||
"doc_upload_failed": "Caricamento non riuscito.",
|
||||
"docs_archive_too_large": "L'archivio è troppo grande (max {max}).",
|
||||
"completion_photos_optional": "Foto di completamento (facoltativo)",
|
||||
"completion_photos": "Foto di completamento",
|
||||
"choose_photos": "Scegli foto",
|
||||
@@ -791,7 +791,11 @@
|
||||
"doc_camera": "Scatta foto",
|
||||
"camera_capture_shoot": "Scatta",
|
||||
"camera_switch_lens": "Cambia fotocamera",
|
||||
"camera_switch_failed": "Nessun'altra fotocamera ha risposto: questo telefono ne espone una sola. Per l'altro obiettivo usa la selezione file.",
|
||||
"camera_switching": "Cambio fotocamera…",
|
||||
"camera_not_answered": "Questa fotocamera non ha risposto. Tocca di nuovo il cambio per provare la successiva o usa la selezione file.",
|
||||
"camera_diagnostics": "Dettagli per una segnalazione di errore",
|
||||
"camera_copy": "Copia",
|
||||
"camera_copied": "Copiato",
|
||||
"doc_drop_hint": "Trascina qui i file",
|
||||
"doc_task_none": "Nessun documento collegato a questa attività.",
|
||||
"doc_link_existing": "Collega un documento…",
|
||||
@@ -844,6 +848,55 @@
|
||||
"reading_below_last": "Inferiore all'ultima lettura ({value})",
|
||||
"settings_templates_label": "Galleria modelli",
|
||||
"settings_templates_hint": "Deseleziona i modelli che non ti serviranno mai: spariscono dai selettori \"Da modello\" (pannello e config flow). Nient'altro cambia; riattivabili in qualsiasi momento.",
|
||||
"home_profile_title": "Profilo dell'abitazione",
|
||||
"home_profile_hint": "Consiglia modelli e imposta le finestre stagionali in base al tuo clima e al tuo emisfero. Tutto viene ricavato localmente da piani, aree e posizione di casa.",
|
||||
"home_type_label": "Tipo di abitazione",
|
||||
"home_type_auto": "Automatico ({detected})",
|
||||
"home_region_label": "Stato, provincia o regione",
|
||||
"home_region_none": "non trovata",
|
||||
"home_region_hint": "Alcune regole cambiano all'interno di un paese: revisione annuale del veicolo in uno stato, nessuna in quello accanto. Ricavato dalla posizione della casa.",
|
||||
"home_dwelling_house": "Casa indipendente",
|
||||
"home_dwelling_apartment": "Appartamento",
|
||||
"home_dwelling_unknown": "non riconosciuto",
|
||||
"home_detected_from": "Riconosciuto da: {reasons}",
|
||||
"home_reason_floors": "più piani",
|
||||
"home_reason_basement": "una cantina",
|
||||
"home_reason_garden": "un giardino",
|
||||
"home_reason_shed": "un capanno",
|
||||
"home_reason_driveway": "un vialetto d'accesso",
|
||||
"home_reason_garage": "un garage",
|
||||
"home_reason_attic": "una soffitta",
|
||||
"home_reason_lawn_mower": "un tosaerba",
|
||||
"home_reason_gate": "un cancello",
|
||||
"home_reason_balcony": "un balcone",
|
||||
"home_reason_small_home": "poche stanze su un solo piano",
|
||||
"home_reason_ups": "un gruppo di continuità",
|
||||
"home_climate_label": "Clima",
|
||||
"home_climate_value": "Köppen {koppen} · mese più freddo {cold} °C · mese più caldo {warm} °C",
|
||||
"home_climate_unknown": "Nessuna posizione di casa impostata in Home Assistant",
|
||||
"home_hemisphere_south": "Emisfero australe — le finestre stagionali sono invertite",
|
||||
"home_trait_freeze": "Gelo in inverno",
|
||||
"home_trait_snow": "Inverni nevosi",
|
||||
"home_trait_severe_winter": "Inverni rigidi",
|
||||
"home_trait_damp": "Clima umido",
|
||||
"home_trait_mediterranean": "Clima mediterraneo",
|
||||
"home_trait_earthquake": "Zona sismica",
|
||||
"home_trait_hot_summer": "Estati calde",
|
||||
"home_trait_hot_humid": "Caldo e umido",
|
||||
"home_trait_hot_dry": "Caldo e secco",
|
||||
"home_trait_termites": "Zona a rischio termiti",
|
||||
"home_trait_wildfire": "Rischio di incendi boschivi",
|
||||
"home_trait_cyclone": "Zona di uragani / tifoni",
|
||||
"home_trait_tropical": "Clima tropicale",
|
||||
"home_trait_radon": "Test del radon consigliato",
|
||||
"home_reason_starter": "Le basi per la tua casa",
|
||||
"home_reason_country": "Comune in {country}",
|
||||
"home_reason_feature": "Presente nella tua casa: {feature}",
|
||||
"templates_recommended_title": "Consigliati per la tua casa",
|
||||
"templates_recommended_hint": "In base al profilo dell'abitazione — modificabile nelle Impostazioni.",
|
||||
"templates_legal_hint": "Le note su obblighi di legge e scadenze sono indicative e non costituiscono consulenza legale. Le regole variano da regione a regione e cambiano nel tempo: prevalgono le norme locali e le istruzioni del produttore.",
|
||||
"templates_not_typical": "Poco comune per il tuo tipo di abitazione",
|
||||
"templates_set_up": "Già configurato",
|
||||
"worksheet": "Scheda di lavoro",
|
||||
"worksheet_scan_view": "Scansiona per aprire l'attività",
|
||||
"worksheet_scan_complete": "Scansiona per completare",
|
||||
@@ -901,6 +954,8 @@
|
||||
"adopt_problem_ok": "ok",
|
||||
"adopt_problem_new_object": "(nuovo)",
|
||||
"adopt_object_hint": "Assegna lo stesso nome oggetto a più sensori e diventano un unico oggetto; digita il nome di un oggetto esistente per aggiungerli lì.",
|
||||
"adopt_summary_title": "Cosa cambia l'adozione",
|
||||
"adopt_summary_existing": "«{name}»: attività {before} → {after}",
|
||||
"adopt_problem_adopt": "Adotta selezionati",
|
||||
"adopt_problem_done": "{tasks} sensore/i di problema adottato/i",
|
||||
"views_label": "Viste",
|
||||
@@ -973,6 +1028,15 @@
|
||||
"trend_easing": "Si allontana dalla soglia",
|
||||
"split_select_hint": "Seleziona un'attività per vederne i dettagli qui",
|
||||
"setups_target_new": "Crea nuovo: {name}",
|
||||
"setups_target_match": "{name} — corrisponde: {reasons}",
|
||||
"setups_reason_model": "numero di modello",
|
||||
"setups_reason_name": "nome",
|
||||
"setups_reason_area": "zona",
|
||||
"setups_reason_sibling": "stesso modello su un altro dispositivo",
|
||||
"setups_maybe_covered": "Probabilmente esiste già come «{name}»",
|
||||
"setups_already": "Già presente: {names}",
|
||||
"setups_before_after": "Attività dell'oggetto: {before} → {after}",
|
||||
"setups_new_object_count": "Nuovo oggetto «{name}» · attività: {count}",
|
||||
"schedule_preview_title": "Prossime date",
|
||||
"schedule_preview_ontime": "Presumendo un completamento puntuale.",
|
||||
"schedule_preview_ends": "(la serie termina)",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"templates_from": "テンプレートから",
|
||||
"templates_title": "テンプレートから始める",
|
||||
"templates_task_count": "{n} 件のタスク",
|
||||
"templates_task_count_one": "1 件のタスク",
|
||||
"template_created": "テンプレートから作成しました",
|
||||
"onboard_hint": "最初のオブジェクトを追加してメンテナンスの記録を始めましょう。",
|
||||
"edit": "編集",
|
||||
@@ -46,6 +47,7 @@
|
||||
"bulk_delete_objects_confirm": "{n} 件のオブジェクトとそのすべてのタスクを削除しますか?アーカイブなら履歴が残ります。",
|
||||
"bulk_objects_deleted": "{n} 件のオブジェクトを削除しました",
|
||||
"bulk_objects_archived": "{n} 件のオブジェクトをアーカイブしました",
|
||||
"bulk_failed": "{n} 件失敗:{reason}",
|
||||
"completing": "完了処理中…",
|
||||
"interval": "間隔",
|
||||
"warning": "警告",
|
||||
@@ -200,8 +202,6 @@
|
||||
"show_all": "すべて表示",
|
||||
"cost_duration_chart": "費用と所要時間",
|
||||
"installed": "設置済み",
|
||||
"confirm_delete_object": "この対象とすべてのタスクを削除しますか?",
|
||||
"confirm_delete_task": "このタスクを削除しますか?",
|
||||
"min": "最小",
|
||||
"max": "最大",
|
||||
"save": "保存",
|
||||
@@ -243,7 +243,6 @@
|
||||
"sort_task_name": "タスク名",
|
||||
"all_objects": "すべての対象",
|
||||
"all_parts": "すべての部品",
|
||||
"tasks_lower": "タスク",
|
||||
"no_tasks_yet": "タスクがまだありません",
|
||||
"add_first_task": "最初のタスクを追加",
|
||||
"trigger_configuration": "トリガー設定",
|
||||
@@ -300,6 +299,7 @@
|
||||
"tab_calendar": "カレンダー",
|
||||
"cal_no_events": "メンテナンスなし",
|
||||
"cal_every_n_days": "{n}日ごと",
|
||||
"cal_every_day": "毎日",
|
||||
"cal_source_time": "時間ベース",
|
||||
"cal_source_time_adaptive": "時間ベース(適応)",
|
||||
"cal_source_sensor": "センサーベース",
|
||||
@@ -685,7 +685,6 @@
|
||||
"budget_open_panel": "パネルで開く",
|
||||
"groups_empty": "グループがまだありません。",
|
||||
"group_new_placeholder": "グループを追加…",
|
||||
"group_delete_confirm": "グループ「{name}」を削除しますか?",
|
||||
"groups_manage_tasks": "タスクの割り当てを管理…",
|
||||
"groups_open_panel": "パネルで開く",
|
||||
"unassigned": "未割り当て",
|
||||
@@ -764,6 +763,7 @@
|
||||
"doc_delete_confirm": "「{name}」を削除しますか?",
|
||||
"doc_too_large": "ファイルが大きすぎます(最大 25 MB)。",
|
||||
"doc_upload_failed": "アップロードに失敗しました。",
|
||||
"docs_archive_too_large": "アーカイブが大きすぎます(最大 {max})。",
|
||||
"completion_photos_optional": "完了写真(任意)",
|
||||
"completion_photos": "完了写真",
|
||||
"choose_photos": "写真を選択",
|
||||
@@ -791,7 +791,11 @@
|
||||
"doc_camera": "写真を撮る",
|
||||
"camera_capture_shoot": "撮影",
|
||||
"camera_switch_lens": "カメラを切り替え",
|
||||
"camera_switch_failed": "他のカメラが応答しませんでした。この端末は1台しか公開していません。別のレンズにはファイル選択を使ってください。",
|
||||
"camera_switching": "カメラを切り替えています…",
|
||||
"camera_not_answered": "このカメラが応答しませんでした。もう一度切り替えをタップして次のカメラを試すか、ファイル選択を使用してください。",
|
||||
"camera_diagnostics": "不具合報告用の詳細",
|
||||
"camera_copy": "コピー",
|
||||
"camera_copied": "コピーしました",
|
||||
"doc_drop_hint": "ここにファイルをドロップ",
|
||||
"doc_task_none": "このタスクに紐付いたドキュメントはありません。",
|
||||
"doc_link_existing": "ドキュメントを紐付け…",
|
||||
@@ -844,6 +848,55 @@
|
||||
"reading_below_last": "前回の値({value})より小さい",
|
||||
"settings_templates_label": "テンプレートギャラリー",
|
||||
"settings_templates_hint": "不要なテンプレートのチェックを外すと、「テンプレートから」の選択肢(パネルと設定フロー)から消えます。それ以外は何も変わらず、いつでも再有効化できます。",
|
||||
"home_profile_title": "住まいのプロフィール",
|
||||
"home_profile_hint": "気候と半球に合わせてテンプレートをおすすめし、季節の期間を設定します。すべてフロア・エリア・自宅の位置からローカルで判定されます。",
|
||||
"home_type_label": "住まいの種類",
|
||||
"home_type_auto": "自動({detected})",
|
||||
"home_region_label": "州・県・地域",
|
||||
"home_region_none": "見つかりません",
|
||||
"home_region_hint": "国内でも地域によって規則が異なることがあります(ある州では毎年の車両検査、隣の州では不要など)。自宅の位置から判定しています。",
|
||||
"home_dwelling_house": "一戸建て",
|
||||
"home_dwelling_apartment": "マンション・アパート",
|
||||
"home_dwelling_unknown": "判定できません",
|
||||
"home_detected_from": "判定の根拠:{reasons}",
|
||||
"home_reason_floors": "複数のフロア",
|
||||
"home_reason_basement": "地下室",
|
||||
"home_reason_garden": "庭",
|
||||
"home_reason_shed": "物置",
|
||||
"home_reason_driveway": "私道",
|
||||
"home_reason_garage": "ガレージ",
|
||||
"home_reason_attic": "屋根裏",
|
||||
"home_reason_lawn_mower": "芝刈り機",
|
||||
"home_reason_gate": "門扉",
|
||||
"home_reason_balcony": "バルコニー",
|
||||
"home_reason_small_home": "1フロアで部屋が少ない",
|
||||
"home_reason_ups": "無停電電源装置(UPS)",
|
||||
"home_climate_label": "気候",
|
||||
"home_climate_value": "ケッペン {koppen} · 最寒月 {cold} °C · 最暖月 {warm} °C",
|
||||
"home_climate_unknown": "Home Assistant に自宅の位置が設定されていません",
|
||||
"home_hemisphere_south": "南半球 — 季節の期間は反転されます",
|
||||
"home_trait_freeze": "冬の凍結",
|
||||
"home_trait_snow": "雪の多い冬",
|
||||
"home_trait_severe_winter": "厳しい冬",
|
||||
"home_trait_damp": "湿潤な気候",
|
||||
"home_trait_mediterranean": "地中海性気候",
|
||||
"home_trait_earthquake": "地震多発地域",
|
||||
"home_trait_hot_summer": "暑い夏",
|
||||
"home_trait_hot_humid": "高温多湿",
|
||||
"home_trait_hot_dry": "高温乾燥",
|
||||
"home_trait_termites": "シロアリ地域",
|
||||
"home_trait_wildfire": "山火事のリスク",
|
||||
"home_trait_cyclone": "ハリケーン・台風地域",
|
||||
"home_trait_tropical": "熱帯気候",
|
||||
"home_trait_radon": "ラドン測定を推奨",
|
||||
"home_reason_starter": "住まいの基本",
|
||||
"home_reason_country": "{country}で一般的",
|
||||
"home_reason_feature": "住まいで検出: {feature}",
|
||||
"templates_recommended_title": "あなたの住まいにおすすめ",
|
||||
"templates_recommended_hint": "住まいのプロフィールに基づきます。設定で変更できます。",
|
||||
"templates_legal_hint": "法的義務や期限に関する注記は目安であり、法的助言ではありません。規則は地域によって異なり、変更されることがあります。地域の法令とメーカーの指示を優先してください。",
|
||||
"templates_not_typical": "この住まいの種類では一般的ではありません",
|
||||
"templates_set_up": "設定済み",
|
||||
"worksheet": "作業シート",
|
||||
"worksheet_scan_view": "スキャンしてタスクを開く",
|
||||
"worksheet_scan_complete": "スキャンして完了",
|
||||
@@ -901,6 +954,8 @@
|
||||
"adopt_problem_ok": "正常",
|
||||
"adopt_problem_new_object": "(新規)",
|
||||
"adopt_object_hint": "複数のセンサーに同じオブジェクト名を付けると1つのオブジェクトになります。既存のオブジェクト名を入力すればそこに追加されます。",
|
||||
"adopt_summary_title": "取り込みで変わること",
|
||||
"adopt_summary_existing": "「{name}」: タスク {before} → {after}",
|
||||
"adopt_problem_adopt": "選択を取り込む",
|
||||
"adopt_problem_done": "{tasks} 件の問題センサーを取り込みました",
|
||||
"views_label": "ビュー",
|
||||
@@ -973,6 +1028,15 @@
|
||||
"trend_easing": "しきい値から遠ざかっています",
|
||||
"split_select_hint": "タスクを選択すると、ここに詳細が表示されます",
|
||||
"setups_target_new": "新規作成:{name}",
|
||||
"setups_target_match": "{name} — 一致: {reasons}",
|
||||
"setups_reason_model": "型番",
|
||||
"setups_reason_name": "名前",
|
||||
"setups_reason_area": "エリア",
|
||||
"setups_reason_sibling": "別のデバイスで同じ機種",
|
||||
"setups_maybe_covered": "「{name}」としてすでにある可能性",
|
||||
"setups_already": "すでにあり: {names}",
|
||||
"setups_before_after": "オブジェクトのタスク: {before} → {after}",
|
||||
"setups_new_object_count": "新しいオブジェクト「{name}」· タスク: {count}",
|
||||
"schedule_preview_title": "次回の予定日",
|
||||
"schedule_preview_ontime": "期日どおりに完了した場合。",
|
||||
"schedule_preview_ends": "(シリーズ終了)",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"templates_from": "템플릿에서",
|
||||
"templates_title": "템플릿으로 시작하기",
|
||||
"templates_task_count": "작업 {n}개",
|
||||
"templates_task_count_one": "작업 1개",
|
||||
"template_created": "템플릿에서 생성됨",
|
||||
"onboard_hint": "첫 객체를 추가하여 유지보수 추적을 시작하세요.",
|
||||
"edit": "편집",
|
||||
@@ -46,6 +47,7 @@
|
||||
"bulk_delete_objects_confirm": "개체 {n}개와 모든 작업을 삭제할까요? 보관하면 기록이 유지됩니다.",
|
||||
"bulk_objects_deleted": "개체 {n}개 삭제됨",
|
||||
"bulk_objects_archived": "개체 {n}개 보관됨",
|
||||
"bulk_failed": "{n}개 실패: {reason}",
|
||||
"completing": "완료 처리 중…",
|
||||
"interval": "주기",
|
||||
"warning": "경고",
|
||||
@@ -200,8 +202,6 @@
|
||||
"show_all": "모두 보기",
|
||||
"cost_duration_chart": "비용 및 소요 시간",
|
||||
"installed": "설치일",
|
||||
"confirm_delete_object": "이 객체와 모든 작업을 삭제할까요?",
|
||||
"confirm_delete_task": "이 작업을 삭제할까요?",
|
||||
"min": "최소",
|
||||
"max": "최대",
|
||||
"save": "저장",
|
||||
@@ -243,7 +243,6 @@
|
||||
"sort_task_name": "작업 이름",
|
||||
"all_objects": "모든 객체",
|
||||
"all_parts": "모든 부품",
|
||||
"tasks_lower": "작업",
|
||||
"no_tasks_yet": "아직 작업 없음",
|
||||
"add_first_task": "첫 작업 추가",
|
||||
"trigger_configuration": "트리거 설정",
|
||||
@@ -300,6 +299,7 @@
|
||||
"tab_calendar": "캘린더",
|
||||
"cal_no_events": "유지보수 없음",
|
||||
"cal_every_n_days": "{n}일마다",
|
||||
"cal_every_day": "매일",
|
||||
"cal_source_time": "시간 기반",
|
||||
"cal_source_time_adaptive": "시간 기반 (적응형)",
|
||||
"cal_source_sensor": "센서 기반",
|
||||
@@ -691,7 +691,6 @@
|
||||
"budget_open_panel": "패널에서 열기",
|
||||
"groups_empty": "아직 그룹이 없습니다.",
|
||||
"group_new_placeholder": "그룹 추가…",
|
||||
"group_delete_confirm": "\"{name}\" 그룹을 삭제할까요?",
|
||||
"groups_manage_tasks": "작업 배정 관리…",
|
||||
"groups_open_panel": "패널에서 열기",
|
||||
"unassigned": "미배정",
|
||||
@@ -770,6 +769,7 @@
|
||||
"doc_delete_confirm": "\"{name}\"을(를) 삭제할까요?",
|
||||
"doc_too_large": "파일이 너무 큽니다 (최대 25 MB).",
|
||||
"doc_upload_failed": "업로드에 실패했습니다.",
|
||||
"docs_archive_too_large": "아카이브가 너무 큽니다(최대 {max}).",
|
||||
"completion_photos_optional": "완료 사진 (선택)",
|
||||
"completion_photos": "완료 사진",
|
||||
"choose_photos": "사진 선택",
|
||||
@@ -797,7 +797,11 @@
|
||||
"doc_camera": "사진 촬영",
|
||||
"camera_capture_shoot": "촬영",
|
||||
"camera_switch_lens": "카메라 전환",
|
||||
"camera_switch_failed": "다른 카메라가 응답하지 않았습니다. 이 휴대폰은 하나만 제공합니다. 다른 렌즈는 파일 선택기를 사용하세요.",
|
||||
"camera_switching": "카메라 전환 중…",
|
||||
"camera_not_answered": "이 카메라가 응답하지 않았습니다. 전환을 다시 눌러 다음 카메라를 시도하거나 파일 선택을 사용하세요.",
|
||||
"camera_diagnostics": "버그 신고용 세부 정보",
|
||||
"camera_copy": "복사",
|
||||
"camera_copied": "복사됨",
|
||||
"doc_drop_hint": "파일을 여기에 놓으세요",
|
||||
"doc_task_none": "이 작업에 연결된 문서가 없습니다.",
|
||||
"doc_link_existing": "문서 연결…",
|
||||
@@ -850,6 +854,55 @@
|
||||
"reading_below_last": "지난 검침값({value})보다 작습니다",
|
||||
"settings_templates_label": "템플릿 갤러리",
|
||||
"settings_templates_hint": "필요 없는 템플릿의 체크를 해제하세요 — \"템플릿에서\" 선택 목록(패널 및 설정 흐름)에서 사라집니다. 다른 변화는 없으며 언제든 다시 활성화할 수 있습니다.",
|
||||
"home_profile_title": "주거 프로필",
|
||||
"home_profile_hint": "기후와 반구에 맞춰 템플릿을 추천하고 계절 기간을 설정합니다. 모든 정보는 층, 영역, 집 위치를 바탕으로 로컬에서 판단됩니다.",
|
||||
"home_type_label": "주거 유형",
|
||||
"home_type_auto": "자동({detected})",
|
||||
"home_region_label": "주, 도 또는 지역",
|
||||
"home_region_none": "찾을 수 없음",
|
||||
"home_region_hint": "일부 규정은 같은 나라 안에서도 다릅니다. 어떤 주는 매년 차량 검사를 하고, 이웃 주는 하지 않습니다. 집 위치로 판단했습니다.",
|
||||
"home_dwelling_house": "주택",
|
||||
"home_dwelling_apartment": "아파트",
|
||||
"home_dwelling_unknown": "인식되지 않음",
|
||||
"home_detected_from": "판단 근거: {reasons}",
|
||||
"home_reason_floors": "여러 층",
|
||||
"home_reason_basement": "지하실",
|
||||
"home_reason_garden": "정원",
|
||||
"home_reason_shed": "창고",
|
||||
"home_reason_driveway": "진입로",
|
||||
"home_reason_garage": "차고",
|
||||
"home_reason_attic": "다락방",
|
||||
"home_reason_lawn_mower": "잔디깎이",
|
||||
"home_reason_gate": "대문",
|
||||
"home_reason_balcony": "발코니",
|
||||
"home_reason_small_home": "한 층에 방이 적음",
|
||||
"home_reason_ups": "무정전 전원 장치(UPS)",
|
||||
"home_climate_label": "기후",
|
||||
"home_climate_value": "쾨펜 {koppen} · 가장 추운 달 {cold} °C · 가장 더운 달 {warm} °C",
|
||||
"home_climate_unknown": "Home Assistant에 집 위치가 설정되지 않았습니다",
|
||||
"home_hemisphere_south": "남반구 — 계절 기간이 반대로 적용됩니다",
|
||||
"home_trait_freeze": "겨울철 서리",
|
||||
"home_trait_snow": "눈이 많은 겨울",
|
||||
"home_trait_severe_winter": "혹독한 겨울",
|
||||
"home_trait_damp": "습한 기후",
|
||||
"home_trait_mediterranean": "지중해성 기후",
|
||||
"home_trait_earthquake": "지진 위험 지역",
|
||||
"home_trait_hot_summer": "무더운 여름",
|
||||
"home_trait_hot_humid": "덥고 습함",
|
||||
"home_trait_hot_dry": "덥고 건조함",
|
||||
"home_trait_termites": "흰개미 서식 지역",
|
||||
"home_trait_wildfire": "산불 위험",
|
||||
"home_trait_cyclone": "허리케인·태풍 지역",
|
||||
"home_trait_tropical": "열대 기후",
|
||||
"home_trait_radon": "라돈 측정 권장",
|
||||
"home_reason_starter": "집의 기본 항목",
|
||||
"home_reason_country": "{country}에서 일반적",
|
||||
"home_reason_feature": "집에서 발견됨: {feature}",
|
||||
"templates_recommended_title": "우리 집 추천",
|
||||
"templates_recommended_hint": "주거 프로필 기준 — 설정에서 조정하세요.",
|
||||
"templates_legal_hint": "법적 의무와 주기에 관한 안내는 참고용이며 법률 자문이 아닙니다. 규정은 지역마다 다르고 바뀔 수 있으니 현지 규정과 제조사 지침을 우선하세요.",
|
||||
"templates_not_typical": "이 주거 유형에는 일반적이지 않음",
|
||||
"templates_set_up": "이미 설정됨",
|
||||
"worksheet": "작업 시트",
|
||||
"worksheet_scan_view": "스캔하여 작업 열기",
|
||||
"worksheet_scan_complete": "스캔하여 완료",
|
||||
@@ -901,6 +954,8 @@
|
||||
"adopt_problem_ok": "정상",
|
||||
"adopt_problem_new_object": "(신규)",
|
||||
"adopt_object_hint": "여러 센서에 같은 개체 이름을 주면 하나의 개체가 됩니다. 기존 개체 이름을 입력하면 거기에 추가됩니다.",
|
||||
"adopt_summary_title": "가져오기로 바뀌는 점",
|
||||
"adopt_summary_existing": "\"{name}\": 작업 {before} → {after}",
|
||||
"adopt_problem_adopt": "선택 항목 가져오기",
|
||||
"adopt_problem_done": "문제 센서 {tasks}개를 가져왔습니다",
|
||||
"views_label": "보기",
|
||||
@@ -973,6 +1028,15 @@
|
||||
"trend_easing": "임계값에서 멀어지는 중",
|
||||
"split_select_hint": "작업을 선택하면 여기에 세부 정보가 표시됩니다",
|
||||
"setups_target_new": "새로 만들기: {name}",
|
||||
"setups_target_match": "{name} — 일치: {reasons}",
|
||||
"setups_reason_model": "모델 번호",
|
||||
"setups_reason_name": "이름",
|
||||
"setups_reason_area": "공간",
|
||||
"setups_reason_sibling": "다른 기기의 같은 모델",
|
||||
"setups_maybe_covered": "이미 \"{name}\"(으)로 있을 수 있음",
|
||||
"setups_already": "이미 있음: {names}",
|
||||
"setups_before_after": "객체의 작업: {before} → {after}",
|
||||
"setups_new_object_count": "새 객체 \"{name}\" · 작업: {count}",
|
||||
"schedule_preview_title": "다음 예정일",
|
||||
"schedule_preview_ontime": "제때 완료한다고 가정한 일정입니다.",
|
||||
"schedule_preview_ends": "(시리즈 종료)",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"templates_from": "Fra mal",
|
||||
"templates_title": "Start fra en mal",
|
||||
"templates_task_count": "{n} oppgaver",
|
||||
"templates_task_count_one": "1 oppgave",
|
||||
"template_created": "Opprettet fra mal",
|
||||
"onboard_hint": "Legg til ditt første objekt for å spore vedlikehold.",
|
||||
"edit": "Rediger",
|
||||
@@ -46,6 +47,7 @@
|
||||
"bulk_delete_objects_confirm": "Slette {n} objekter og alle oppgavene deres? Arkivering beholder historikken i stedet.",
|
||||
"bulk_objects_deleted": "{n} objekter slettet",
|
||||
"bulk_objects_archived": "{n} objekter arkivert",
|
||||
"bulk_failed": "{n} mislyktes: {reason}",
|
||||
"completing": "Fullfører…",
|
||||
"interval": "Intervall",
|
||||
"warning": "Advarsel",
|
||||
@@ -200,8 +202,6 @@
|
||||
"show_all": "Vis alle",
|
||||
"cost_duration_chart": "Kostnad og varighet",
|
||||
"installed": "Installert",
|
||||
"confirm_delete_object": "Slette dette objektet og alle dets oppgaver?",
|
||||
"confirm_delete_task": "Slette denne oppgaven?",
|
||||
"min": "Min",
|
||||
"max": "Maks",
|
||||
"save": "Lagre",
|
||||
@@ -243,7 +243,6 @@
|
||||
"sort_task_name": "Oppgavenavn",
|
||||
"all_objects": "Alle objekter",
|
||||
"all_parts": "Alle deler",
|
||||
"tasks_lower": "oppgaver",
|
||||
"no_tasks_yet": "Ingen oppgaver ennå",
|
||||
"add_first_task": "Legg til første oppgave",
|
||||
"trigger_configuration": "Utløserkonfigurasjon",
|
||||
@@ -300,6 +299,7 @@
|
||||
"tab_calendar": "Kalender",
|
||||
"cal_no_events": "Ingen vedlikehold",
|
||||
"cal_every_n_days": "hver {n}. dag",
|
||||
"cal_every_day": "hver dag",
|
||||
"cal_source_time": "Tidsbasert",
|
||||
"cal_source_time_adaptive": "Tidsbasert (adaptiv)",
|
||||
"cal_source_sensor": "Sensorbasert",
|
||||
@@ -685,7 +685,6 @@
|
||||
"budget_open_panel": "Åpne i panel",
|
||||
"groups_empty": "Ingen grupper ennå.",
|
||||
"group_new_placeholder": "Legg til gruppe…",
|
||||
"group_delete_confirm": "Slette gruppen \"{name}\"?",
|
||||
"groups_manage_tasks": "Administrer oppgavetildelinger…",
|
||||
"groups_open_panel": "Åpne i panel",
|
||||
"unassigned": "Ikke tildelt",
|
||||
@@ -764,6 +763,7 @@
|
||||
"doc_delete_confirm": "Slette \"{name}\"?",
|
||||
"doc_too_large": "Filen er for stor (maks. 25 MB).",
|
||||
"doc_upload_failed": "Opplasting mislyktes.",
|
||||
"docs_archive_too_large": "Arkivet er for stort (maks. {max}).",
|
||||
"completion_photos_optional": "Bilder ved fullføring (valgfritt)",
|
||||
"completion_photos": "Bilder ved fullføring",
|
||||
"choose_photos": "Velg bilder",
|
||||
@@ -791,7 +791,11 @@
|
||||
"doc_camera": "Ta bilde",
|
||||
"camera_capture_shoot": "Ta bilde",
|
||||
"camera_switch_lens": "Bytt kamera",
|
||||
"camera_switch_failed": "Ingen andre kameraer svarte – denne telefonen gir bare fra seg ett. Bruk filvelgeren for det andre objektivet.",
|
||||
"camera_switching": "Bytter kamera…",
|
||||
"camera_not_answered": "Dette kameraet svarte ikke. Trykk på bytt igjen for å prøve det neste, eller bruk filvelgeren.",
|
||||
"camera_diagnostics": "Detaljer for en feilrapport",
|
||||
"camera_copy": "Kopier",
|
||||
"camera_copied": "Kopiert",
|
||||
"doc_drop_hint": "Slipp filer her",
|
||||
"doc_task_none": "Ingen dokumenter knyttet til denne oppgaven.",
|
||||
"doc_link_existing": "Knytt et dokument…",
|
||||
@@ -844,6 +848,55 @@
|
||||
"reading_below_last": "Lavere enn forrige avlesning ({value})",
|
||||
"settings_templates_label": "Malgalleri",
|
||||
"settings_templates_hint": "Fjern haken for maler du aldri trenger — de forsvinner fra \"Fra mal\"-velgerne (panel og config flow). Ellers endres ingenting; kan aktiveres igjen når som helst.",
|
||||
"home_profile_title": "Boligprofil",
|
||||
"home_profile_hint": "Anbefaler maler og setter sesongvinduer etter ditt klima og din halvkule. Alt beregnes lokalt ut fra etasjene, områdene og boligens plassering.",
|
||||
"home_type_label": "Boligtype",
|
||||
"home_type_auto": "Automatisk ({detected})",
|
||||
"home_region_label": "Delstat, provins eller region",
|
||||
"home_region_none": "ikke funnet",
|
||||
"home_region_hint": "Noen regler varierer innenfor et land – årlig kjøretøykontroll i én delstat, ingen i den neste. Funnet ut fra hjemmets plassering.",
|
||||
"home_dwelling_house": "Hus",
|
||||
"home_dwelling_apartment": "Leilighet",
|
||||
"home_dwelling_unknown": "ikke gjenkjent",
|
||||
"home_detected_from": "Gjenkjent ut fra: {reasons}",
|
||||
"home_reason_floors": "flere etasjer",
|
||||
"home_reason_basement": "en kjeller",
|
||||
"home_reason_garden": "en hage",
|
||||
"home_reason_shed": "et uthus",
|
||||
"home_reason_driveway": "en innkjørsel",
|
||||
"home_reason_garage": "en garasje",
|
||||
"home_reason_attic": "et loft",
|
||||
"home_reason_lawn_mower": "en gressklipper",
|
||||
"home_reason_gate": "en port",
|
||||
"home_reason_balcony": "en balkong",
|
||||
"home_reason_small_home": "få rom på ett plan",
|
||||
"home_reason_ups": "en UPS-enhet",
|
||||
"home_climate_label": "Klima",
|
||||
"home_climate_value": "Köppen {koppen} · kaldeste måned {cold} °C · varmeste måned {warm} °C",
|
||||
"home_climate_unknown": "Ingen hjemplassering er angitt i Home Assistant",
|
||||
"home_hemisphere_south": "Den sørlige halvkule — sesongvinduer speilvendes",
|
||||
"home_trait_freeze": "Frost om vinteren",
|
||||
"home_trait_snow": "Snørike vintre",
|
||||
"home_trait_severe_winter": "Harde vintre",
|
||||
"home_trait_damp": "Fuktig klima",
|
||||
"home_trait_mediterranean": "Middelhavsklima",
|
||||
"home_trait_earthquake": "Jordskjelvområde",
|
||||
"home_trait_hot_summer": "Varme somre",
|
||||
"home_trait_hot_humid": "Varmt og fuktig",
|
||||
"home_trait_hot_dry": "Varmt og tørt",
|
||||
"home_trait_termites": "Termittområde",
|
||||
"home_trait_wildfire": "Fare for skogbrann",
|
||||
"home_trait_cyclone": "Orkan-/tyfonområde",
|
||||
"home_trait_tropical": "Tropisk klima",
|
||||
"home_trait_radon": "Radonmåling anbefales",
|
||||
"home_reason_starter": "Det grunnleggende for hjemmet ditt",
|
||||
"home_reason_country": "Vanlig i {country}",
|
||||
"home_reason_feature": "Funnet i hjemmet ditt: {feature}",
|
||||
"templates_recommended_title": "Anbefalt for hjemmet ditt",
|
||||
"templates_recommended_hint": "Basert på boligprofilen din — kan endres under Innstillinger.",
|
||||
"templates_legal_hint": "Merknader om lovpålagte plikter og frister er veiledende og ikke juridisk rådgivning. Reglene varierer fra region til region og endres over tid — lokale forskrifter og produsentens anvisninger går foran.",
|
||||
"templates_not_typical": "Uvanlig for din boligtype",
|
||||
"templates_set_up": "Allerede satt opp",
|
||||
"worksheet": "Arbeidsark",
|
||||
"worksheet_scan_view": "Skann for å åpne oppgaven",
|
||||
"worksheet_scan_complete": "Skann for å fullføre",
|
||||
@@ -901,6 +954,8 @@
|
||||
"adopt_problem_ok": "ok",
|
||||
"adopt_problem_new_object": "(ny)",
|
||||
"adopt_object_hint": "Gi flere sensorer samme objektnavn, så blir de ett objekt; skriv navnet på et eksisterende objekt for å legge dem der.",
|
||||
"adopt_summary_title": "Hva overtakelsen endrer",
|
||||
"adopt_summary_existing": "«{name}»: oppgaver {before} → {after}",
|
||||
"adopt_problem_adopt": "Ta i bruk valgte",
|
||||
"adopt_problem_done": "Tok i bruk {tasks} problemsensor(er)",
|
||||
"views_label": "Visninger",
|
||||
@@ -973,6 +1028,15 @@
|
||||
"trend_easing": "Beveger seg bort fra terskelen",
|
||||
"split_select_hint": "Velg en oppgave for å se detaljene her",
|
||||
"setups_target_new": "Opprett ny: {name}",
|
||||
"setups_target_match": "{name} — passer: {reasons}",
|
||||
"setups_reason_model": "modellnummer",
|
||||
"setups_reason_name": "navn",
|
||||
"setups_reason_area": "område",
|
||||
"setups_reason_sibling": "samme modell på en annen enhet",
|
||||
"setups_maybe_covered": "Finnes sannsynligvis allerede som «{name}»",
|
||||
"setups_already": "Finnes allerede: {names}",
|
||||
"setups_before_after": "Oppgaver på objektet: {before} → {after}",
|
||||
"setups_new_object_count": "Nytt objekt «{name}» · oppgaver: {count}",
|
||||
"schedule_preview_title": "Kommende datoer",
|
||||
"schedule_preview_ontime": "Forutsatt fullføring i tide.",
|
||||
"schedule_preview_ends": "(serien avsluttes)",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"templates_from": "Uit sjabloon",
|
||||
"templates_title": "Begin met een sjabloon",
|
||||
"templates_task_count": "{n} taken",
|
||||
"templates_task_count_one": "1 taak",
|
||||
"template_created": "Aangemaakt uit sjabloon",
|
||||
"onboard_hint": "Voeg je eerste object toe om onderhoud te volgen.",
|
||||
"edit": "Bewerken",
|
||||
@@ -44,6 +45,7 @@
|
||||
"bulk_delete_objects_confirm": "{n} objecten met al hun taken verwijderen? Archiveren bewaart in plaats daarvan de geschiedenis.",
|
||||
"bulk_objects_deleted": "{n} objecten verwijderd",
|
||||
"bulk_objects_archived": "{n} objecten gearchiveerd",
|
||||
"bulk_failed": "{n} mislukt: {reason}",
|
||||
"completing": "Wordt voltooid…",
|
||||
"interval": "Interval",
|
||||
"warning": "Waarschuwing",
|
||||
@@ -198,8 +200,6 @@
|
||||
"show_all": "Alles tonen",
|
||||
"cost_duration_chart": "Kosten & Duur",
|
||||
"installed": "Geïnstalleerd",
|
||||
"confirm_delete_object": "Dit object en alle bijbehorende taken verwijderen?",
|
||||
"confirm_delete_task": "Deze taak verwijderen?",
|
||||
"min": "Min",
|
||||
"max": "Max",
|
||||
"save": "Opslaan",
|
||||
@@ -241,7 +241,6 @@
|
||||
"sort_task_name": "Taaknaam",
|
||||
"all_objects": "Alle objecten",
|
||||
"all_parts": "Alle onderdelen",
|
||||
"tasks_lower": "taken",
|
||||
"no_tasks_yet": "Nog geen taken",
|
||||
"add_first_task": "Eerste taak toevoegen",
|
||||
"trigger_configuration": "Triggerconfiguratie",
|
||||
@@ -298,6 +297,7 @@
|
||||
"tab_calendar": "Kalender",
|
||||
"cal_no_events": "Geen onderhoud",
|
||||
"cal_every_n_days": "elke {n} dagen",
|
||||
"cal_every_day": "elke dag",
|
||||
"cal_source_time": "Tijd-gebaseerd",
|
||||
"cal_source_time_adaptive": "Tijd-gebaseerd (adaptief)",
|
||||
"cal_source_sensor": "Sensor-gebaseerd",
|
||||
@@ -713,7 +713,6 @@
|
||||
"budget_open_panel": "Openen in paneel",
|
||||
"groups_empty": "Nog geen groepen.",
|
||||
"group_new_placeholder": "Groep toevoegen…",
|
||||
"group_delete_confirm": "Groep \"{name}\" verwijderen?",
|
||||
"groups_manage_tasks": "Taaktoewijzingen beheren…",
|
||||
"groups_open_panel": "Openen in paneel",
|
||||
"on_complete_action_target_hint": "Let op: het domein van de entiteit moet bij de service passen — bijv. 'button.press' werkt alleen op button.*, 'counter.increment' alleen op counter.*, 'input_button.press' alleen op input_button.* enz. Bij een mismatch mislukt de actie stil (HA logt 'Referenced entities ... missing or not currently available').",
|
||||
@@ -764,6 +763,7 @@
|
||||
"doc_delete_confirm": "\"{name}\" verwijderen?",
|
||||
"doc_too_large": "Bestand is te groot (max. 25 MB).",
|
||||
"doc_upload_failed": "Uploaden mislukt.",
|
||||
"docs_archive_too_large": "Het archief is te groot (max. {max}).",
|
||||
"completion_photos_optional": "Foto's bij afronding (optioneel)",
|
||||
"completion_photos": "Foto's bij afronding",
|
||||
"choose_photos": "Foto's kiezen",
|
||||
@@ -791,7 +791,11 @@
|
||||
"doc_camera": "Foto maken",
|
||||
"camera_capture_shoot": "Vastleggen",
|
||||
"camera_switch_lens": "Camera wisselen",
|
||||
"camera_switch_failed": "Geen andere camera reageerde — deze telefoon geeft er maar één vrij. Gebruik de bestandskiezer voor de andere lens.",
|
||||
"camera_switching": "Camera wisselen…",
|
||||
"camera_not_answered": "Deze camera reageerde niet. Tik opnieuw op wisselen om de volgende te proberen, of gebruik de bestandskiezer.",
|
||||
"camera_diagnostics": "Details voor een foutmelding",
|
||||
"camera_copy": "Kopiëren",
|
||||
"camera_copied": "Gekopieerd",
|
||||
"doc_drop_hint": "Sleep bestanden hierheen",
|
||||
"doc_task_none": "Geen documenten aan deze taak gekoppeld.",
|
||||
"doc_link_existing": "Document koppelen…",
|
||||
@@ -844,6 +848,55 @@
|
||||
"reading_below_last": "Lager dan de vorige meterstand ({value})",
|
||||
"settings_templates_label": "Sjabloongalerij",
|
||||
"settings_templates_hint": "Vink sjablonen uit die je nooit nodig hebt — ze verdwijnen uit de \"Vanuit sjabloon\"-keuzes (paneel en config flow). Verder verandert er niets; altijd weer in te schakelen.",
|
||||
"home_profile_title": "Woningprofiel",
|
||||
"home_profile_hint": "Beveelt sjablonen aan en stelt seizoensvensters in voor je klimaat en halfrond. Alles wordt lokaal bepaald uit je verdiepingen, ruimtes en locatie.",
|
||||
"home_type_label": "Woningtype",
|
||||
"home_type_auto": "Automatisch ({detected})",
|
||||
"home_region_label": "Staat, provincie of regio",
|
||||
"home_region_none": "niet gevonden",
|
||||
"home_region_hint": "Sommige regels verschillen binnen een land: een jaarlijkse voertuigkeuring in de ene staat, geen in de volgende. Bepaald op basis van de locatie van je woning.",
|
||||
"home_dwelling_house": "Huis",
|
||||
"home_dwelling_apartment": "Appartement",
|
||||
"home_dwelling_unknown": "niet herkend",
|
||||
"home_detected_from": "Herkend aan: {reasons}",
|
||||
"home_reason_floors": "meerdere verdiepingen",
|
||||
"home_reason_basement": "een kelder",
|
||||
"home_reason_garden": "een tuin",
|
||||
"home_reason_shed": "een schuur",
|
||||
"home_reason_driveway": "een oprit",
|
||||
"home_reason_garage": "een garage",
|
||||
"home_reason_attic": "een zolder",
|
||||
"home_reason_lawn_mower": "een grasmaaier",
|
||||
"home_reason_gate": "een poort",
|
||||
"home_reason_balcony": "een balkon",
|
||||
"home_reason_small_home": "weinig ruimtes op één verdieping",
|
||||
"home_reason_ups": "een noodstroomvoeding (UPS)",
|
||||
"home_climate_label": "Klimaat",
|
||||
"home_climate_value": "Köppen {koppen} · koudste maand {cold} °C · warmste maand {warm} °C",
|
||||
"home_climate_unknown": "Geen thuislocatie ingesteld in Home Assistant",
|
||||
"home_hemisphere_south": "Zuidelijk halfrond — seizoensvensters worden gespiegeld",
|
||||
"home_trait_freeze": "Vorst in de winter",
|
||||
"home_trait_snow": "Sneeuwrijke winters",
|
||||
"home_trait_severe_winter": "Strenge winters",
|
||||
"home_trait_damp": "Vochtig klimaat",
|
||||
"home_trait_mediterranean": "Mediterraan klimaat",
|
||||
"home_trait_earthquake": "Aardbevingsgebied",
|
||||
"home_trait_hot_summer": "Hete zomers",
|
||||
"home_trait_hot_humid": "Heet en vochtig",
|
||||
"home_trait_hot_dry": "Heet en droog",
|
||||
"home_trait_termites": "Termietengebied",
|
||||
"home_trait_wildfire": "Risico op natuurbranden",
|
||||
"home_trait_cyclone": "Orkaan-/tyfoongebied",
|
||||
"home_trait_tropical": "Tropisch klimaat",
|
||||
"home_trait_radon": "Radontest aanbevolen",
|
||||
"home_reason_starter": "Basis voor je woning",
|
||||
"home_reason_country": "Gebruikelijk in {country}",
|
||||
"home_reason_feature": "Gevonden in je woning: {feature}",
|
||||
"templates_recommended_title": "Aanbevolen voor je woning",
|
||||
"templates_recommended_hint": "Op basis van je woningprofiel — aan te passen in Instellingen.",
|
||||
"templates_legal_hint": "Opmerkingen over wettelijke verplichtingen en termijnen zijn een richtlijn, geen juridisch advies. Regels verschillen per regio en veranderen — lokale voorschriften en de instructies van de fabrikant gaan voor.",
|
||||
"templates_not_typical": "Niet gebruikelijk voor je woningtype",
|
||||
"templates_set_up": "Al ingesteld",
|
||||
"worksheet": "Werkblad",
|
||||
"worksheet_scan_view": "Scan om de taak te openen",
|
||||
"worksheet_scan_complete": "Scan om af te ronden",
|
||||
@@ -901,6 +954,8 @@
|
||||
"adopt_problem_ok": "ok",
|
||||
"adopt_problem_new_object": "(nieuw)",
|
||||
"adopt_object_hint": "Geef meerdere sensoren dezelfde objectnaam en ze worden één object; typ de naam van een bestaand object om ze daar toe te voegen.",
|
||||
"adopt_summary_title": "Wat overnemen verandert",
|
||||
"adopt_summary_existing": "\"{name}\": taken {before} → {after}",
|
||||
"adopt_problem_adopt": "Selectie overnemen",
|
||||
"adopt_problem_done": "{tasks} probleemsensor(en) overgenomen",
|
||||
"views_label": "Weergaven",
|
||||
@@ -973,6 +1028,15 @@
|
||||
"trend_easing": "Beweegt weg van de drempel",
|
||||
"split_select_hint": "Selecteer een taak om hier de details te zien",
|
||||
"setups_target_new": "Nieuw aanmaken: {name}",
|
||||
"setups_target_match": "{name} — komt overeen: {reasons}",
|
||||
"setups_reason_model": "modelnummer",
|
||||
"setups_reason_name": "naam",
|
||||
"setups_reason_area": "ruimte",
|
||||
"setups_reason_sibling": "zelfde model op een ander apparaat",
|
||||
"setups_maybe_covered": "Waarschijnlijk al aanwezig als \"{name}\"",
|
||||
"setups_already": "Al aanwezig: {names}",
|
||||
"setups_before_after": "Taken van het object: {before} → {after}",
|
||||
"setups_new_object_count": "Nieuw object \"{name}\" · taken: {count}",
|
||||
"schedule_preview_title": "Volgende data",
|
||||
"schedule_preview_ontime": "Uitgaande van tijdige afronding.",
|
||||
"schedule_preview_ends": "(reeks eindigt)",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"templates_from": "Z szablonu",
|
||||
"templates_title": "Zacznij od szablonu",
|
||||
"templates_task_count": "{n} zadań",
|
||||
"templates_task_count_one": "1 zadanie",
|
||||
"template_created": "Utworzono z szablonu",
|
||||
"onboard_hint": "Dodaj pierwszy obiekt, aby śledzić konserwację.",
|
||||
"edit": "Edytuj",
|
||||
@@ -44,6 +45,7 @@
|
||||
"bulk_delete_objects_confirm": "Usunąć {n} obiektów wraz ze wszystkimi zadaniami? Archiwizacja zachowa ich historię.",
|
||||
"bulk_objects_deleted": "Usunięto obiektów: {n}",
|
||||
"bulk_objects_archived": "Zarchiwizowano obiektów: {n}",
|
||||
"bulk_failed": "Nieudane: {n} — {reason}",
|
||||
"completing": "Wykonywanie…",
|
||||
"interval": "Interwał",
|
||||
"warning": "Ostrzeżenie",
|
||||
@@ -198,8 +200,6 @@
|
||||
"show_all": "Pokaż wszystko",
|
||||
"cost_duration_chart": "Koszt i czas trwania",
|
||||
"installed": "Zainstalowane",
|
||||
"confirm_delete_object": "Usunąć ten obiekt i wszystkie jego zadania?",
|
||||
"confirm_delete_task": "Usunąć to zadanie?",
|
||||
"min": "Min",
|
||||
"max": "Maks",
|
||||
"save": "Zapisz",
|
||||
@@ -241,7 +241,6 @@
|
||||
"sort_task_name": "Nazwa zadania",
|
||||
"all_objects": "Wszystkie obiekty",
|
||||
"all_parts": "Wszystkie części",
|
||||
"tasks_lower": "zadań",
|
||||
"no_tasks_yet": "Jeszcze brak zadań",
|
||||
"add_first_task": "Dodaj pierwsze zadanie",
|
||||
"trigger_configuration": "Konfiguracja wyzwalacza",
|
||||
@@ -298,6 +297,7 @@
|
||||
"tab_calendar": "Kalendarz",
|
||||
"cal_no_events": "Brak konserwacji",
|
||||
"cal_every_n_days": "co {n} dni",
|
||||
"cal_every_day": "codziennie",
|
||||
"cal_source_time": "Czas",
|
||||
"cal_source_time_adaptive": "Czas (adaptacyjny)",
|
||||
"cal_source_sensor": "Czujnik",
|
||||
@@ -713,7 +713,6 @@
|
||||
"budget_open_panel": "Otwórz w panelu",
|
||||
"groups_empty": "Brak grup.",
|
||||
"group_new_placeholder": "Dodaj grupę…",
|
||||
"group_delete_confirm": "Usunąć grupę „{name}”?",
|
||||
"groups_manage_tasks": "Zarządzaj przypisaniami zadań…",
|
||||
"groups_open_panel": "Otwórz w panelu",
|
||||
"on_complete_action_target_hint": "Uwaga: domena encji musi pasować do usługi — np. 'button.press' działa tylko na button.*, 'counter.increment' tylko na counter.*, 'input_button.press' tylko na input_button.* itd. W przypadku niezgodności akcja po cichu się nie wykona (HA zapisze w dzienniku 'Referenced entities ... missing or not currently available').",
|
||||
@@ -764,6 +763,7 @@
|
||||
"doc_delete_confirm": "Usunąć „{name}”?",
|
||||
"doc_too_large": "Plik jest za duży (maks. 25 MB).",
|
||||
"doc_upload_failed": "Przesyłanie nie powiodło się.",
|
||||
"docs_archive_too_large": "Archiwum jest za duże (maks. {max}).",
|
||||
"completion_photos_optional": "Zdjęcia z wykonania (opcjonalnie)",
|
||||
"completion_photos": "Zdjęcia z wykonania",
|
||||
"choose_photos": "Wybierz zdjęcia",
|
||||
@@ -791,7 +791,11 @@
|
||||
"doc_camera": "Zrób zdjęcie",
|
||||
"camera_capture_shoot": "Zrób zdjęcie",
|
||||
"camera_switch_lens": "Przełącz kamerę",
|
||||
"camera_switch_failed": "Żadna inna kamera nie odpowiedziała — ten telefon udostępnia tylko jedną. Dla drugiego obiektywu użyj wyboru pliku.",
|
||||
"camera_switching": "Przełączanie aparatu…",
|
||||
"camera_not_answered": "Ten aparat nie odpowiedział. Stuknij ponownie przełącznik, aby spróbować następnego, lub użyj wyboru pliku.",
|
||||
"camera_diagnostics": "Szczegóły do zgłoszenia błędu",
|
||||
"camera_copy": "Kopiuj",
|
||||
"camera_copied": "Skopiowano",
|
||||
"doc_drop_hint": "Upuść pliki tutaj",
|
||||
"doc_task_none": "Brak dokumentów powiązanych z tym zadaniem.",
|
||||
"doc_link_existing": "Powiąż dokument…",
|
||||
@@ -844,6 +848,55 @@
|
||||
"reading_below_last": "Mniej niż ostatni odczyt ({value})",
|
||||
"settings_templates_label": "Galeria szablonów",
|
||||
"settings_templates_hint": "Odznacz szablony, których nigdy nie użyjesz — znikną z wyboru „Z szablonu\" (panel i config flow). Nic więcej się nie zmienia; można je włączyć ponownie w każdej chwili.",
|
||||
"home_profile_title": "Profil domu",
|
||||
"home_profile_hint": "Poleca szablony i ustawia okna sezonowe zgodnie z Twoim klimatem i półkulą. Wszystko jest ustalane lokalnie na podstawie pięter, obszarów i lokalizacji domu.",
|
||||
"home_type_label": "Rodzaj domu",
|
||||
"home_type_auto": "Automatycznie ({detected})",
|
||||
"home_region_label": "Stan, prowincja lub region",
|
||||
"home_region_none": "nie znaleziono",
|
||||
"home_region_hint": "Niektóre przepisy różnią się w obrębie kraju – w jednym stanie coroczny przegląd pojazdu, w sąsiednim żaden. Ustalono na podstawie lokalizacji domu.",
|
||||
"home_dwelling_house": "Dom",
|
||||
"home_dwelling_apartment": "Mieszkanie",
|
||||
"home_dwelling_unknown": "nie rozpoznano",
|
||||
"home_detected_from": "Rozpoznano po: {reasons}",
|
||||
"home_reason_floors": "kilka pięter",
|
||||
"home_reason_basement": "piwnica",
|
||||
"home_reason_garden": "ogród",
|
||||
"home_reason_shed": "szopa",
|
||||
"home_reason_driveway": "podjazd",
|
||||
"home_reason_garage": "garaż",
|
||||
"home_reason_attic": "strych",
|
||||
"home_reason_lawn_mower": "kosiarka",
|
||||
"home_reason_gate": "brama",
|
||||
"home_reason_balcony": "balkon",
|
||||
"home_reason_small_home": "mało pomieszczeń na jednym piętrze",
|
||||
"home_reason_ups": "zasilacz UPS",
|
||||
"home_climate_label": "Klimat",
|
||||
"home_climate_value": "Köppen {koppen} · najzimniejszy miesiąc {cold} °C · najcieplejszy miesiąc {warm} °C",
|
||||
"home_climate_unknown": "Nie ustawiono lokalizacji domu w Home Assistant",
|
||||
"home_hemisphere_south": "Półkula południowa — okna sezonowe są odwrócone",
|
||||
"home_trait_freeze": "Mrozy zimą",
|
||||
"home_trait_snow": "Śnieżne zimy",
|
||||
"home_trait_severe_winter": "Surowe zimy",
|
||||
"home_trait_damp": "Wilgotny klimat",
|
||||
"home_trait_mediterranean": "Klimat śródziemnomorski",
|
||||
"home_trait_earthquake": "Obszar sejsmiczny",
|
||||
"home_trait_hot_summer": "Upalne lata",
|
||||
"home_trait_hot_humid": "Gorąco i wilgotno",
|
||||
"home_trait_hot_dry": "Gorąco i sucho",
|
||||
"home_trait_termites": "Obszar występowania termitów",
|
||||
"home_trait_wildfire": "Ryzyko pożarów lasów",
|
||||
"home_trait_cyclone": "Obszar huraganów / tajfunów",
|
||||
"home_trait_tropical": "Klimat tropikalny",
|
||||
"home_trait_radon": "Zalecany pomiar radonu",
|
||||
"home_reason_starter": "Podstawy dla Twojego domu",
|
||||
"home_reason_country": "Popularne w kraju: {country}",
|
||||
"home_reason_feature": "Znaleziono w Twoim domu: {feature}",
|
||||
"templates_recommended_title": "Polecane dla Twojego domu",
|
||||
"templates_recommended_hint": "Na podstawie profilu domu — możesz go zmienić w Ustawieniach.",
|
||||
"templates_legal_hint": "Informacje o obowiązkach prawnych i terminach mają charakter orientacyjny i nie stanowią porady prawnej. Przepisy różnią się w zależności od regionu i zmieniają się — pierwszeństwo mają lokalne przepisy i instrukcje producenta.",
|
||||
"templates_not_typical": "Nietypowe dla Twojego rodzaju domu",
|
||||
"templates_set_up": "Już skonfigurowano",
|
||||
"worksheet": "Karta pracy",
|
||||
"worksheet_scan_view": "Zeskanuj, aby otworzyć zadanie",
|
||||
"worksheet_scan_complete": "Zeskanuj, aby ukończyć",
|
||||
@@ -901,6 +954,8 @@
|
||||
"adopt_problem_ok": "ok",
|
||||
"adopt_problem_new_object": "(nowy)",
|
||||
"adopt_object_hint": "Nadaj kilku czujnikom tę samą nazwę obiektu, a staną się jednym obiektem; wpisz nazwę istniejącego obiektu, aby je tam dodać.",
|
||||
"adopt_summary_title": "Co zmieni przejęcie",
|
||||
"adopt_summary_existing": "„{name}”: zadania {before} → {after}",
|
||||
"adopt_problem_adopt": "Przejmij zaznaczone",
|
||||
"adopt_problem_done": "Przejęto {tasks} czujnik(ów) problemów",
|
||||
"views_label": "Widoki",
|
||||
@@ -973,6 +1028,15 @@
|
||||
"trend_easing": "Oddala się od progu",
|
||||
"split_select_hint": "Wybierz zadanie, aby zobaczyć tutaj szczegóły",
|
||||
"setups_target_new": "Utwórz nowy: {name}",
|
||||
"setups_target_match": "{name} — pasuje: {reasons}",
|
||||
"setups_reason_model": "numer modelu",
|
||||
"setups_reason_name": "nazwa",
|
||||
"setups_reason_area": "obszar",
|
||||
"setups_reason_sibling": "ten sam model na innym urządzeniu",
|
||||
"setups_maybe_covered": "Prawdopodobnie już istnieje jako „{name}”",
|
||||
"setups_already": "Już istnieje: {names}",
|
||||
"setups_before_after": "Zadania obiektu: {before} → {after}",
|
||||
"setups_new_object_count": "Nowy obiekt „{name}” · zadania: {count}",
|
||||
"schedule_preview_title": "Najbliższe terminy",
|
||||
"schedule_preview_ontime": "Przy założeniu terminowego ukończenia.",
|
||||
"schedule_preview_ends": "(seria się kończy)",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"templates_from": "A partir de modelo",
|
||||
"templates_title": "Comece com um modelo",
|
||||
"templates_task_count": "{n} tarefas",
|
||||
"templates_task_count_one": "1 tarefa",
|
||||
"template_created": "Criado a partir do modelo",
|
||||
"onboard_hint": "Adicione seu primeiro objeto para começar a acompanhar a manutenção.",
|
||||
"edit": "Editar",
|
||||
@@ -46,6 +47,7 @@
|
||||
"bulk_delete_objects_confirm": "Excluir {n} objetos e todas as suas tarefas? Arquivar mantém o histórico.",
|
||||
"bulk_objects_deleted": "{n} objetos excluídos",
|
||||
"bulk_objects_archived": "{n} objetos arquivados",
|
||||
"bulk_failed": "{n} falharam: {reason}",
|
||||
"completing": "Concluindo…",
|
||||
"interval": "Intervalo",
|
||||
"warning": "Aviso",
|
||||
@@ -200,8 +202,6 @@
|
||||
"show_all": "Mostrar tudo",
|
||||
"cost_duration_chart": "Custo e duração",
|
||||
"installed": "Instalado",
|
||||
"confirm_delete_object": "Excluir este objeto e todas as suas tarefas?",
|
||||
"confirm_delete_task": "Excluir esta tarefa?",
|
||||
"min": "Mín",
|
||||
"max": "Máx",
|
||||
"save": "Salvar",
|
||||
@@ -243,7 +243,6 @@
|
||||
"sort_task_name": "Nome da tarefa",
|
||||
"all_objects": "Todos os objetos",
|
||||
"all_parts": "Todas as peças",
|
||||
"tasks_lower": "tarefas",
|
||||
"no_tasks_yet": "Ainda sem tarefas",
|
||||
"add_first_task": "Adicionar a primeira tarefa",
|
||||
"trigger_configuration": "Configuração do gatilho",
|
||||
@@ -300,6 +299,7 @@
|
||||
"tab_calendar": "Calendário",
|
||||
"cal_no_events": "Sem manutenção",
|
||||
"cal_every_n_days": "a cada {n} dias",
|
||||
"cal_every_day": "todos os dias",
|
||||
"cal_source_time": "Baseado em tempo",
|
||||
"cal_source_time_adaptive": "Baseado em tempo (adaptativo)",
|
||||
"cal_source_sensor": "Baseado em sensor",
|
||||
@@ -691,7 +691,6 @@
|
||||
"budget_open_panel": "Abrir no painel",
|
||||
"groups_empty": "Ainda sem grupos.",
|
||||
"group_new_placeholder": "Adicionar grupo…",
|
||||
"group_delete_confirm": "Excluir o grupo \"{name}\"?",
|
||||
"groups_manage_tasks": "Gerenciar atribuições de tarefas…",
|
||||
"groups_open_panel": "Abrir no painel",
|
||||
"unassigned": "Sem atribuição",
|
||||
@@ -770,6 +769,7 @@
|
||||
"doc_delete_confirm": "Excluir \"{name}\"?",
|
||||
"doc_too_large": "O arquivo é muito grande (máx. 25 MB).",
|
||||
"doc_upload_failed": "Falha no envio.",
|
||||
"docs_archive_too_large": "O arquivo é grande demais (máx. {max}).",
|
||||
"completion_photos_optional": "Fotos de conclusão (opcional)",
|
||||
"completion_photos": "Fotos de conclusão",
|
||||
"choose_photos": "Escolher fotos",
|
||||
@@ -797,7 +797,11 @@
|
||||
"doc_camera": "Tirar foto",
|
||||
"camera_capture_shoot": "Capturar",
|
||||
"camera_switch_lens": "Trocar de câmera",
|
||||
"camera_switch_failed": "Nenhuma outra câmera respondeu — este celular libera só uma. Use o seletor de arquivos para a outra lente.",
|
||||
"camera_switching": "Trocando de câmera…",
|
||||
"camera_not_answered": "Esta câmera não respondeu. Toque de novo em trocar para tentar a próxima ou use o seletor de arquivos.",
|
||||
"camera_diagnostics": "Detalhes para um relatório de erro",
|
||||
"camera_copy": "Copiar",
|
||||
"camera_copied": "Copiado",
|
||||
"doc_drop_hint": "Solte os arquivos aqui",
|
||||
"doc_task_none": "Nenhum documento vinculado a esta tarefa.",
|
||||
"doc_link_existing": "Vincular um documento…",
|
||||
@@ -850,6 +854,55 @@
|
||||
"reading_below_last": "Menor que a última leitura ({value})",
|
||||
"settings_templates_label": "Galeria de modelos",
|
||||
"settings_templates_hint": "Desmarque os modelos de que você nunca precisará — eles somem dos seletores \"A partir de modelo\" (painel e fluxo de configuração). Nada mais muda; você pode reativá-los a qualquer momento.",
|
||||
"home_profile_title": "Perfil da residência",
|
||||
"home_profile_hint": "Recomenda modelos e define janelas sazonais de acordo com seu clima e hemisfério. Tudo é calculado localmente a partir dos seus andares, áreas e localização da casa.",
|
||||
"home_type_label": "Tipo de moradia",
|
||||
"home_type_auto": "Automático ({detected})",
|
||||
"home_region_label": "Estado, província ou região",
|
||||
"home_region_none": "não encontrada",
|
||||
"home_region_hint": "Algumas regras variam dentro de um país: vistoria anual do veículo em um estado, nenhuma no vizinho. Determinado pela localização da sua casa.",
|
||||
"home_dwelling_house": "Casa",
|
||||
"home_dwelling_apartment": "Apartamento",
|
||||
"home_dwelling_unknown": "não reconhecido",
|
||||
"home_detected_from": "Detectado por: {reasons}",
|
||||
"home_reason_floors": "vários andares",
|
||||
"home_reason_basement": "um porão",
|
||||
"home_reason_garden": "um quintal",
|
||||
"home_reason_shed": "um galpão",
|
||||
"home_reason_driveway": "uma entrada de garagem",
|
||||
"home_reason_garage": "uma garagem",
|
||||
"home_reason_attic": "um sótão",
|
||||
"home_reason_lawn_mower": "um cortador de grama",
|
||||
"home_reason_gate": "um portão",
|
||||
"home_reason_balcony": "uma sacada",
|
||||
"home_reason_small_home": "poucos cômodos em um só andar",
|
||||
"home_reason_ups": "um nobreak",
|
||||
"home_climate_label": "Clima",
|
||||
"home_climate_value": "Köppen {koppen} · mês mais frio {cold} °C · mês mais quente {warm} °C",
|
||||
"home_climate_unknown": "Nenhuma localização de casa definida no Home Assistant",
|
||||
"home_hemisphere_south": "Hemisfério sul — as janelas sazonais são invertidas",
|
||||
"home_trait_freeze": "Geada no inverno",
|
||||
"home_trait_snow": "Invernos com neve",
|
||||
"home_trait_severe_winter": "Invernos rigorosos",
|
||||
"home_trait_damp": "Clima úmido",
|
||||
"home_trait_mediterranean": "Clima mediterrâneo",
|
||||
"home_trait_earthquake": "Região sísmica",
|
||||
"home_trait_hot_summer": "Verões quentes",
|
||||
"home_trait_hot_humid": "Quente e úmido",
|
||||
"home_trait_hot_dry": "Quente e seco",
|
||||
"home_trait_termites": "Região de cupins",
|
||||
"home_trait_wildfire": "Risco de incêndios florestais",
|
||||
"home_trait_cyclone": "Região de furacões / tufões",
|
||||
"home_trait_tropical": "Clima tropical",
|
||||
"home_trait_radon": "Teste de radônio recomendado",
|
||||
"home_reason_starter": "O essencial para sua casa",
|
||||
"home_reason_country": "Comum em {country}",
|
||||
"home_reason_feature": "Encontrado na sua casa: {feature}",
|
||||
"templates_recommended_title": "Recomendado para sua casa",
|
||||
"templates_recommended_hint": "Com base no perfil da residência — ajuste em Configurações.",
|
||||
"templates_legal_hint": "As notas sobre obrigações legais e prazos são apenas orientação, não aconselhamento jurídico. As regras variam conforme a região e mudam com o tempo — prevalecem a regulamentação local e as instruções do fabricante.",
|
||||
"templates_not_typical": "Pouco comum no seu tipo de moradia",
|
||||
"templates_set_up": "Já configurado",
|
||||
"worksheet": "Folha de trabalho",
|
||||
"worksheet_scan_view": "Escaneie para abrir a tarefa",
|
||||
"worksheet_scan_complete": "Escaneie para concluir",
|
||||
@@ -901,6 +954,8 @@
|
||||
"adopt_problem_ok": "ok",
|
||||
"adopt_problem_new_object": "(novo)",
|
||||
"adopt_object_hint": "Dê a vários sensores o mesmo nome de objeto e eles viram um único objeto; digite o nome de um objeto existente para adicioná-los lá.",
|
||||
"adopt_summary_title": "O que a adoção muda",
|
||||
"adopt_summary_existing": "\"{name}\": tarefas {before} → {after}",
|
||||
"adopt_problem_adopt": "Adotar selecionados",
|
||||
"adopt_problem_done": "{tasks} sensor(es) de problema adotado(s)",
|
||||
"views_label": "Visualizações",
|
||||
@@ -973,6 +1028,15 @@
|
||||
"trend_easing": "Afastando-se do limite",
|
||||
"split_select_hint": "Selecione uma tarefa para ver os detalhes aqui",
|
||||
"setups_target_new": "Criar novo: {name}",
|
||||
"setups_target_match": "{name} — corresponde: {reasons}",
|
||||
"setups_reason_model": "número do modelo",
|
||||
"setups_reason_name": "nome",
|
||||
"setups_reason_area": "área",
|
||||
"setups_reason_sibling": "mesmo modelo em outro dispositivo",
|
||||
"setups_maybe_covered": "Provavelmente já existe como \"{name}\"",
|
||||
"setups_already": "Já existe: {names}",
|
||||
"setups_before_after": "Tarefas do objeto: {before} → {after}",
|
||||
"setups_new_object_count": "Novo objeto \"{name}\" · tarefas: {count}",
|
||||
"schedule_preview_title": "Próximas datas",
|
||||
"schedule_preview_ontime": "Supondo conclusão em dia.",
|
||||
"schedule_preview_ends": "(a série termina)",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"templates_from": "De modelo",
|
||||
"templates_title": "Começar a partir de um modelo",
|
||||
"templates_task_count": "{n} tarefas",
|
||||
"templates_task_count_one": "1 tarefa",
|
||||
"template_created": "Criado a partir de modelo",
|
||||
"onboard_hint": "Adicione o seu primeiro objeto para acompanhar a manutenção.",
|
||||
"edit": "Editar",
|
||||
@@ -44,6 +45,7 @@
|
||||
"bulk_delete_objects_confirm": "Eliminar {n} objetos e todas as suas tarefas? Arquivar mantém o histórico.",
|
||||
"bulk_objects_deleted": "{n} objetos eliminados",
|
||||
"bulk_objects_archived": "{n} objetos arquivados",
|
||||
"bulk_failed": "{n} falharam: {reason}",
|
||||
"completing": "A concluir…",
|
||||
"interval": "Intervalo",
|
||||
"warning": "Aviso",
|
||||
@@ -198,8 +200,6 @@
|
||||
"show_all": "Mostrar tudo",
|
||||
"cost_duration_chart": "Custos & Duração",
|
||||
"installed": "Instalado",
|
||||
"confirm_delete_object": "Eliminar este objeto e todas as suas tarefas?",
|
||||
"confirm_delete_task": "Eliminar esta tarefa?",
|
||||
"min": "Mín",
|
||||
"max": "Máx",
|
||||
"save": "Guardar",
|
||||
@@ -241,7 +241,6 @@
|
||||
"sort_task_name": "Nome da tarefa",
|
||||
"all_objects": "Todos os objetos",
|
||||
"all_parts": "Todas as peças",
|
||||
"tasks_lower": "tarefas",
|
||||
"no_tasks_yet": "Ainda sem tarefas",
|
||||
"add_first_task": "Adicionar primeira tarefa",
|
||||
"trigger_configuration": "Configuração do acionador",
|
||||
@@ -298,6 +297,7 @@
|
||||
"tab_calendar": "Calendário",
|
||||
"cal_no_events": "Sem manutenção",
|
||||
"cal_every_n_days": "a cada {n} dias",
|
||||
"cal_every_day": "todos os dias",
|
||||
"cal_source_time": "Baseado em tempo",
|
||||
"cal_source_time_adaptive": "Baseado em tempo (adaptativo)",
|
||||
"cal_source_sensor": "Baseado em sensor",
|
||||
@@ -713,7 +713,6 @@
|
||||
"budget_open_panel": "Abrir no painel",
|
||||
"groups_empty": "Ainda sem grupos.",
|
||||
"group_new_placeholder": "Adicionar grupo…",
|
||||
"group_delete_confirm": "Eliminar o grupo \"{name}\"?",
|
||||
"groups_manage_tasks": "Gerir atribuições de tarefas…",
|
||||
"groups_open_panel": "Abrir no painel",
|
||||
"on_complete_action_target_hint": "Nota: o domínio da entidade tem de corresponder ao serviço — p. ex. 'button.press' só funciona em button.*, 'counter.increment' só em counter.*, 'input_button.press' só em input_button.* etc. Em caso de incompatibilidade, a ação falha silenciosamente (o HA regista 'Referenced entities ... missing or not currently available').",
|
||||
@@ -764,6 +763,7 @@
|
||||
"doc_delete_confirm": "Eliminar «{name}»?",
|
||||
"doc_too_large": "Ficheiro demasiado grande (máx. 25 MB).",
|
||||
"doc_upload_failed": "Falha no carregamento.",
|
||||
"docs_archive_too_large": "O arquivo é demasiado grande (máx. {max}).",
|
||||
"completion_photos_optional": "Fotos de conclusão (opcional)",
|
||||
"completion_photos": "Fotos de conclusão",
|
||||
"choose_photos": "Escolher fotos",
|
||||
@@ -791,7 +791,11 @@
|
||||
"doc_camera": "Tirar foto",
|
||||
"camera_capture_shoot": "Capturar",
|
||||
"camera_switch_lens": "Mudar de câmara",
|
||||
"camera_switch_failed": "Nenhuma outra câmara respondeu — este telemóvel só disponibiliza uma. Use o seletor de ficheiros para a outra objetiva.",
|
||||
"camera_switching": "A mudar de câmara…",
|
||||
"camera_not_answered": "Esta câmara não respondeu. Toque novamente em mudar para experimentar a seguinte ou use o seletor de ficheiros.",
|
||||
"camera_diagnostics": "Detalhes para comunicar um erro",
|
||||
"camera_copy": "Copiar",
|
||||
"camera_copied": "Copiado",
|
||||
"doc_drop_hint": "Solte os ficheiros aqui",
|
||||
"doc_task_none": "Nenhum documento associado a esta tarefa.",
|
||||
"doc_link_existing": "Associar um documento…",
|
||||
@@ -844,6 +848,55 @@
|
||||
"reading_below_last": "Inferior à última leitura ({value})",
|
||||
"settings_templates_label": "Galeria de modelos",
|
||||
"settings_templates_hint": "Desmarque modelos de que nunca precisará — desaparecem dos seletores \"A partir de modelo\" (painel e config flow). Nada mais muda; reativáveis a qualquer momento.",
|
||||
"home_profile_title": "Perfil da habitação",
|
||||
"home_profile_hint": "Recomenda modelos e define janelas sazonais de acordo com o seu clima e hemisfério. Tudo é determinado localmente a partir dos seus pisos, áreas e localização da casa.",
|
||||
"home_type_label": "Tipo de habitação",
|
||||
"home_type_auto": "Automático ({detected})",
|
||||
"home_region_label": "Estado, província ou região",
|
||||
"home_region_none": "não encontrada",
|
||||
"home_region_hint": "Algumas regras variam dentro de um país: inspeção anual do veículo num estado, nenhuma no seguinte. Determinado a partir da localização da casa.",
|
||||
"home_dwelling_house": "Moradia",
|
||||
"home_dwelling_apartment": "Apartamento",
|
||||
"home_dwelling_unknown": "não reconhecido",
|
||||
"home_detected_from": "Detetado por: {reasons}",
|
||||
"home_reason_floors": "vários pisos",
|
||||
"home_reason_basement": "uma cave",
|
||||
"home_reason_garden": "um jardim",
|
||||
"home_reason_shed": "um barracão",
|
||||
"home_reason_driveway": "um acesso de garagem",
|
||||
"home_reason_garage": "uma garagem",
|
||||
"home_reason_attic": "um sótão",
|
||||
"home_reason_lawn_mower": "um corta-relva",
|
||||
"home_reason_gate": "um portão",
|
||||
"home_reason_balcony": "uma varanda",
|
||||
"home_reason_small_home": "poucas divisões num só piso",
|
||||
"home_reason_ups": "uma UPS",
|
||||
"home_climate_label": "Clima",
|
||||
"home_climate_value": "Köppen {koppen} · mês mais frio {cold} °C · mês mais quente {warm} °C",
|
||||
"home_climate_unknown": "Nenhuma localização de casa definida no Home Assistant",
|
||||
"home_hemisphere_south": "Hemisfério sul — as janelas sazonais são invertidas",
|
||||
"home_trait_freeze": "Geada no inverno",
|
||||
"home_trait_snow": "Invernos com neve",
|
||||
"home_trait_severe_winter": "Invernos rigorosos",
|
||||
"home_trait_damp": "Clima húmido",
|
||||
"home_trait_mediterranean": "Clima mediterrânico",
|
||||
"home_trait_earthquake": "Zona sísmica",
|
||||
"home_trait_hot_summer": "Verões quentes",
|
||||
"home_trait_hot_humid": "Quente e húmido",
|
||||
"home_trait_hot_dry": "Quente e seco",
|
||||
"home_trait_termites": "Zona de térmitas",
|
||||
"home_trait_wildfire": "Risco de incêndios florestais",
|
||||
"home_trait_cyclone": "Zona de furacões / tufões",
|
||||
"home_trait_tropical": "Clima tropical",
|
||||
"home_trait_radon": "Teste de radão recomendado",
|
||||
"home_reason_starter": "O essencial para a sua casa",
|
||||
"home_reason_country": "Comum em {country}",
|
||||
"home_reason_feature": "Encontrado na sua casa: {feature}",
|
||||
"templates_recommended_title": "Recomendado para a sua casa",
|
||||
"templates_recommended_hint": "Com base no perfil da habitação — ajuste-o nas Definições.",
|
||||
"templates_legal_hint": "As notas sobre obrigações legais e prazos são orientativas e não constituem aconselhamento jurídico. As regras variam consoante a região e mudam com o tempo — prevalecem a regulamentação local e as instruções do fabricante.",
|
||||
"templates_not_typical": "Pouco comum no seu tipo de habitação",
|
||||
"templates_set_up": "Já configurado",
|
||||
"worksheet": "Folha de trabalho",
|
||||
"worksheet_scan_view": "Digitalize para abrir a tarefa",
|
||||
"worksheet_scan_complete": "Digitalize para concluir",
|
||||
@@ -901,6 +954,8 @@
|
||||
"adopt_problem_ok": "ok",
|
||||
"adopt_problem_new_object": "(novo)",
|
||||
"adopt_object_hint": "Dê a vários sensores o mesmo nome de objeto e tornam-se um só objeto; escreva o nome de um objeto existente para os juntar lá.",
|
||||
"adopt_summary_title": "O que a adoção altera",
|
||||
"adopt_summary_existing": "«{name}»: tarefas {before} → {after}",
|
||||
"adopt_problem_adopt": "Adotar selecionados",
|
||||
"adopt_problem_done": "{tasks} sensor(es) de problema adotado(s)",
|
||||
"views_label": "Vistas",
|
||||
@@ -973,6 +1028,15 @@
|
||||
"trend_easing": "A afastar-se do limiar",
|
||||
"split_select_hint": "Selecione uma tarefa para ver os detalhes aqui",
|
||||
"setups_target_new": "Criar novo: {name}",
|
||||
"setups_target_match": "{name} — corresponde: {reasons}",
|
||||
"setups_reason_model": "número do modelo",
|
||||
"setups_reason_name": "nome",
|
||||
"setups_reason_area": "divisão",
|
||||
"setups_reason_sibling": "mesmo modelo noutro dispositivo",
|
||||
"setups_maybe_covered": "Provavelmente já existe como «{name}»",
|
||||
"setups_already": "Já existe: {names}",
|
||||
"setups_before_after": "Tarefas do objeto: {before} → {after}",
|
||||
"setups_new_object_count": "Novo objeto «{name}» · tarefas: {count}",
|
||||
"schedule_preview_title": "Próximas datas",
|
||||
"schedule_preview_ontime": "Assumindo conclusão pontual.",
|
||||
"schedule_preview_ends": "(a série termina)",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"templates_from": "Из шаблона",
|
||||
"templates_title": "Начать с шаблона",
|
||||
"templates_task_count": "{n} задач",
|
||||
"templates_task_count_one": "1 задача",
|
||||
"template_created": "Создано из шаблона",
|
||||
"onboard_hint": "Добавьте первый объект, чтобы отслеживать обслуживание.",
|
||||
"edit": "Изменить",
|
||||
@@ -44,6 +45,7 @@
|
||||
"bulk_delete_objects_confirm": "Удалить {n} объектов со всеми их задачами? Архивация вместо этого сохранит историю.",
|
||||
"bulk_objects_deleted": "Удалено объектов: {n}",
|
||||
"bulk_objects_archived": "Архивировано объектов: {n}",
|
||||
"bulk_failed": "Не удалось: {n} — {reason}",
|
||||
"completing": "Выполнение…",
|
||||
"interval": "Интервал",
|
||||
"warning": "Предупреждение",
|
||||
@@ -198,8 +200,6 @@
|
||||
"show_all": "Показать все",
|
||||
"cost_duration_chart": "Стоимость и длительность",
|
||||
"installed": "Установлен",
|
||||
"confirm_delete_object": "Удалить этот объект и все его задачи?",
|
||||
"confirm_delete_task": "Удалить эту задачу?",
|
||||
"min": "Мин",
|
||||
"max": "Макс",
|
||||
"save": "Сохранить",
|
||||
@@ -240,7 +240,6 @@
|
||||
"sort_task_name": "Имя задачи",
|
||||
"all_objects": "Все объекты",
|
||||
"all_parts": "Все детали",
|
||||
"tasks_lower": "задач",
|
||||
"no_tasks_yet": "Пока нет задач",
|
||||
"add_first_task": "Добавить первую задачу",
|
||||
"last_performed_optional": "Последнее выполнение (опционально)",
|
||||
@@ -298,6 +297,7 @@
|
||||
"tab_calendar": "Календарь",
|
||||
"cal_no_events": "Без обслуживания",
|
||||
"cal_every_n_days": "каждые {n} дней",
|
||||
"cal_every_day": "каждый день",
|
||||
"cal_source_time": "По времени",
|
||||
"cal_source_time_adaptive": "По времени (адаптивно)",
|
||||
"cal_source_sensor": "По датчику",
|
||||
@@ -713,7 +713,6 @@
|
||||
"budget_open_panel": "Открыть на панели",
|
||||
"groups_empty": "Пока нет групп.",
|
||||
"group_new_placeholder": "Добавить группу…",
|
||||
"group_delete_confirm": "Удалить группу «{name}»?",
|
||||
"groups_manage_tasks": "Управление назначениями задач…",
|
||||
"groups_open_panel": "Открыть на панели",
|
||||
"on_complete_action_target_hint": "Примечание: домен сущности должен соответствовать сервису — напр. 'button.press' работает только с button.*, 'counter.increment' только с counter.*, 'input_button.press' только с input_button.* и т. д. При несоответствии действие молча не выполнится (HA запишет в журнал 'Referenced entities ... missing or not currently available').",
|
||||
@@ -764,6 +763,7 @@
|
||||
"doc_delete_confirm": "Удалить «{name}»?",
|
||||
"doc_too_large": "Файл слишком большой (макс. 25 МБ).",
|
||||
"doc_upload_failed": "Не удалось загрузить.",
|
||||
"docs_archive_too_large": "Архив слишком большой (макс. {max}).",
|
||||
"completion_photos_optional": "Фото выполнения (необязательно)",
|
||||
"completion_photos": "Фото выполнения",
|
||||
"choose_photos": "Выбрать фото",
|
||||
@@ -791,7 +791,11 @@
|
||||
"doc_camera": "Сделать фото",
|
||||
"camera_capture_shoot": "Снять",
|
||||
"camera_switch_lens": "Сменить камеру",
|
||||
"camera_switch_failed": "Другая камера не ответила — этот телефон отдаёт только одну. Для другого объектива используйте выбор файла.",
|
||||
"camera_switching": "Переключение камеры…",
|
||||
"camera_not_answered": "Эта камера не ответила. Нажмите переключение ещё раз, чтобы попробовать следующую, или выберите файл.",
|
||||
"camera_diagnostics": "Подробности для отчёта об ошибке",
|
||||
"camera_copy": "Копировать",
|
||||
"camera_copied": "Скопировано",
|
||||
"doc_drop_hint": "Перетащите файлы сюда",
|
||||
"doc_task_none": "Нет документов, связанных с этой задачей.",
|
||||
"doc_link_existing": "Связать документ…",
|
||||
@@ -844,6 +848,55 @@
|
||||
"reading_below_last": "Меньше предыдущего показания ({value})",
|
||||
"settings_templates_label": "Галерея шаблонов",
|
||||
"settings_templates_hint": "Снимите отметку с шаблонов, которые вам не нужны — они исчезнут из выбора «Из шаблона» (панель и config flow). Больше ничего не меняется; можно включить обратно в любой момент.",
|
||||
"home_profile_title": "Профиль жилья",
|
||||
"home_profile_hint": "Рекомендует шаблоны и задаёт сезонные окна по вашему климату и полушарию. Всё определяется локально по вашим этажам, зонам и местоположению дома.",
|
||||
"home_type_label": "Тип жилья",
|
||||
"home_type_auto": "Автоматически ({detected})",
|
||||
"home_region_label": "Штат, провинция или регион",
|
||||
"home_region_none": "не найден",
|
||||
"home_region_hint": "Некоторые правила различаются внутри страны: в одном штате техосмотр автомобиля ежегодный, в соседнем его нет. Определено по местоположению дома.",
|
||||
"home_dwelling_house": "Дом",
|
||||
"home_dwelling_apartment": "Квартира",
|
||||
"home_dwelling_unknown": "не определено",
|
||||
"home_detected_from": "Определено по: {reasons}",
|
||||
"home_reason_floors": "несколько этажей",
|
||||
"home_reason_basement": "подвал",
|
||||
"home_reason_garden": "сад",
|
||||
"home_reason_shed": "сарай",
|
||||
"home_reason_driveway": "подъездная дорожка",
|
||||
"home_reason_garage": "гараж",
|
||||
"home_reason_attic": "чердак",
|
||||
"home_reason_lawn_mower": "газонокосилка",
|
||||
"home_reason_gate": "ворота",
|
||||
"home_reason_balcony": "балкон",
|
||||
"home_reason_small_home": "мало комнат на одном этаже",
|
||||
"home_reason_ups": "ИБП",
|
||||
"home_climate_label": "Климат",
|
||||
"home_climate_value": "Кёппен {koppen} · самый холодный месяц {cold} °C · самый тёплый месяц {warm} °C",
|
||||
"home_climate_unknown": "В Home Assistant не задано местоположение дома",
|
||||
"home_hemisphere_south": "Южное полушарие — сезонные окна зеркально смещены",
|
||||
"home_trait_freeze": "Морозы зимой",
|
||||
"home_trait_snow": "Снежные зимы",
|
||||
"home_trait_severe_winter": "Суровые зимы",
|
||||
"home_trait_damp": "Влажный климат",
|
||||
"home_trait_mediterranean": "Средиземноморский климат",
|
||||
"home_trait_earthquake": "Сейсмоопасный район",
|
||||
"home_trait_hot_summer": "Жаркое лето",
|
||||
"home_trait_hot_humid": "Жарко и влажно",
|
||||
"home_trait_hot_dry": "Жарко и сухо",
|
||||
"home_trait_termites": "Район термитов",
|
||||
"home_trait_wildfire": "Риск лесных пожаров",
|
||||
"home_trait_cyclone": "Район ураганов / тайфунов",
|
||||
"home_trait_tropical": "Тропический климат",
|
||||
"home_trait_radon": "Рекомендуется замер радона",
|
||||
"home_reason_starter": "Основы для вашего дома",
|
||||
"home_reason_country": "Распространено: {country}",
|
||||
"home_reason_feature": "Найдено в вашем доме: {feature}",
|
||||
"templates_recommended_title": "Рекомендовано для вашего дома",
|
||||
"templates_recommended_hint": "На основе профиля жилья — можно изменить в настройках.",
|
||||
"templates_legal_hint": "Указания на обязательные требования и сроки носят справочный характер и не являются юридической консультацией. Правила различаются по регионам и меняются — приоритет имеют местные нормы и инструкции производителя.",
|
||||
"templates_not_typical": "Нетипично для вашего типа жилья",
|
||||
"templates_set_up": "Уже настроено",
|
||||
"worksheet": "Рабочий лист",
|
||||
"worksheet_scan_view": "Сканируйте, чтобы открыть задачу",
|
||||
"worksheet_scan_complete": "Сканируйте для выполнения",
|
||||
@@ -901,6 +954,8 @@
|
||||
"adopt_problem_ok": "норма",
|
||||
"adopt_problem_new_object": "(новый)",
|
||||
"adopt_object_hint": "Дайте нескольким датчикам одно имя объекта — они станут одним объектом; введите имя существующего объекта, чтобы добавить их туда.",
|
||||
"adopt_summary_title": "Что изменит добавление",
|
||||
"adopt_summary_existing": "«{name}»: задачи {before} → {after}",
|
||||
"adopt_problem_adopt": "Принять выбранные",
|
||||
"adopt_problem_done": "Принято сенсоров проблем: {tasks}",
|
||||
"views_label": "Представления",
|
||||
@@ -973,6 +1028,15 @@
|
||||
"trend_easing": "Отдаляется от порога",
|
||||
"split_select_hint": "Выберите задачу, чтобы увидеть здесь её детали",
|
||||
"setups_target_new": "Создать новый: {name}",
|
||||
"setups_target_match": "{name} — совпадает: {reasons}",
|
||||
"setups_reason_model": "номер модели",
|
||||
"setups_reason_name": "название",
|
||||
"setups_reason_area": "зона",
|
||||
"setups_reason_sibling": "та же модель на другом устройстве",
|
||||
"setups_maybe_covered": "Вероятно, уже есть как «{name}»",
|
||||
"setups_already": "Уже есть: {names}",
|
||||
"setups_before_after": "Задачи объекта: {before} → {after}",
|
||||
"setups_new_object_count": "Новый объект «{name}» · задачи: {count}",
|
||||
"schedule_preview_title": "Ближайшие даты",
|
||||
"schedule_preview_ontime": "При условии своевременного выполнения.",
|
||||
"schedule_preview_ends": "(серия завершается)",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"templates_from": "Från mall",
|
||||
"templates_title": "Börja från en mall",
|
||||
"templates_task_count": "{n} uppgifter",
|
||||
"templates_task_count_one": "1 uppgift",
|
||||
"template_created": "Skapad från mall",
|
||||
"onboard_hint": "Lägg till ditt första objekt för att spåra underhåll.",
|
||||
"edit": "Redigera",
|
||||
@@ -44,6 +45,7 @@
|
||||
"bulk_delete_objects_confirm": "Ta bort {n} objekt och alla deras uppgifter? Arkivering behåller historiken i stället.",
|
||||
"bulk_objects_deleted": "{n} objekt borttagna",
|
||||
"bulk_objects_archived": "{n} objekt arkiverade",
|
||||
"bulk_failed": "{n} misslyckades: {reason}",
|
||||
"completing": "Slutför…",
|
||||
"interval": "Intervall",
|
||||
"warning": "Varning",
|
||||
@@ -198,8 +200,6 @@
|
||||
"show_all": "Visa alla",
|
||||
"cost_duration_chart": "Kostnad och varaktighet",
|
||||
"installed": "Installerad",
|
||||
"confirm_delete_object": "Ta bort detta objekt och alla dess uppgifter?",
|
||||
"confirm_delete_task": "Ta bort denna uppgift?",
|
||||
"min": "Min",
|
||||
"max": "Max",
|
||||
"save": "Spara",
|
||||
@@ -241,7 +241,6 @@
|
||||
"sort_task_name": "Uppgiftsnamn",
|
||||
"all_objects": "Alla objekt",
|
||||
"all_parts": "Alla delar",
|
||||
"tasks_lower": "uppgifter",
|
||||
"no_tasks_yet": "Inga uppgifter ännu",
|
||||
"add_first_task": "Lägg till första uppgift",
|
||||
"trigger_configuration": "Utlösarkonfiguration",
|
||||
@@ -298,6 +297,7 @@
|
||||
"tab_calendar": "Kalender",
|
||||
"cal_no_events": "Inget underhåll",
|
||||
"cal_every_n_days": "var {n} dag",
|
||||
"cal_every_day": "varje dag",
|
||||
"cal_source_time": "Tidsbaserad",
|
||||
"cal_source_time_adaptive": "Tidsbaserad (adaptiv)",
|
||||
"cal_source_sensor": "Sensorbaserad",
|
||||
@@ -713,7 +713,6 @@
|
||||
"budget_open_panel": "Öppna i panelen",
|
||||
"groups_empty": "Inga grupper än.",
|
||||
"group_new_placeholder": "Lägg till grupp…",
|
||||
"group_delete_confirm": "Ta bort gruppen ”{name}”?",
|
||||
"groups_manage_tasks": "Hantera uppgiftstilldelningar…",
|
||||
"groups_open_panel": "Öppna i panelen",
|
||||
"on_complete_action_target_hint": "Obs: entitetens domän måste matcha tjänsten — t.ex. 'button.press' fungerar bara på button.*, 'counter.increment' bara på counter.*, 'input_button.press' bara på input_button.* osv. Vid en avvikelse misslyckas åtgärden tyst (HA loggar 'Referenced entities ... missing or not currently available').",
|
||||
@@ -764,6 +763,7 @@
|
||||
"doc_delete_confirm": "Ta bort \"{name}\"?",
|
||||
"doc_too_large": "Filen är för stor (max 25 MB).",
|
||||
"doc_upload_failed": "Uppladdningen misslyckades.",
|
||||
"docs_archive_too_large": "Arkivet är för stort (max {max}).",
|
||||
"completion_photos_optional": "Foton vid slutförande (valfritt)",
|
||||
"completion_photos": "Foton vid slutförande",
|
||||
"choose_photos": "Välj foton",
|
||||
@@ -791,7 +791,11 @@
|
||||
"doc_camera": "Ta foto",
|
||||
"camera_capture_shoot": "Ta bild",
|
||||
"camera_switch_lens": "Byt kamera",
|
||||
"camera_switch_failed": "Ingen annan kamera svarade – den här telefonen lämnar bara ut en. Använd filväljaren för det andra objektivet.",
|
||||
"camera_switching": "Byter kamera…",
|
||||
"camera_not_answered": "Den här kameran svarade inte. Tryck på byt igen för att prova nästa, eller använd filväljaren.",
|
||||
"camera_diagnostics": "Detaljer för en felrapport",
|
||||
"camera_copy": "Kopiera",
|
||||
"camera_copied": "Kopierat",
|
||||
"doc_drop_hint": "Släpp filer här",
|
||||
"doc_task_none": "Inga dokument kopplade till denna uppgift.",
|
||||
"doc_link_existing": "Koppla ett dokument…",
|
||||
@@ -844,6 +848,55 @@
|
||||
"reading_below_last": "Lägre än senaste avläsningen ({value})",
|
||||
"settings_templates_label": "Mallgalleri",
|
||||
"settings_templates_hint": "Avmarkera mallar du aldrig behöver — de försvinner från \"Från mall\"-väljarna (panel och config flow). Inget annat ändras; kan återaktiveras när som helst.",
|
||||
"home_profile_title": "Bostadsprofil",
|
||||
"home_profile_hint": "Rekommenderar mallar och ställer in säsongsfönster efter ditt klimat och halvklot. Allt räknas ut lokalt från dina våningar, områden och hemmets plats.",
|
||||
"home_type_label": "Bostadstyp",
|
||||
"home_type_auto": "Automatiskt ({detected})",
|
||||
"home_region_label": "Delstat, provins eller region",
|
||||
"home_region_none": "hittades inte",
|
||||
"home_region_hint": "Vissa regler skiljer sig inom ett land – årlig fordonsbesiktning i en delstat, ingen i nästa. Hämtat från hemmets plats.",
|
||||
"home_dwelling_house": "Hus",
|
||||
"home_dwelling_apartment": "Lägenhet",
|
||||
"home_dwelling_unknown": "inte identifierad",
|
||||
"home_detected_from": "Identifierad utifrån: {reasons}",
|
||||
"home_reason_floors": "flera våningar",
|
||||
"home_reason_basement": "en källare",
|
||||
"home_reason_garden": "en trädgård",
|
||||
"home_reason_shed": "ett förråd",
|
||||
"home_reason_driveway": "en uppfart",
|
||||
"home_reason_garage": "ett garage",
|
||||
"home_reason_attic": "en vind",
|
||||
"home_reason_lawn_mower": "en gräsklippare",
|
||||
"home_reason_gate": "en grind",
|
||||
"home_reason_balcony": "en balkong",
|
||||
"home_reason_small_home": "få rum på ett plan",
|
||||
"home_reason_ups": "en UPS-enhet",
|
||||
"home_climate_label": "Klimat",
|
||||
"home_climate_value": "Köppen {koppen} · kallaste månaden {cold} °C · varmaste månaden {warm} °C",
|
||||
"home_climate_unknown": "Ingen hemplats angiven i Home Assistant",
|
||||
"home_hemisphere_south": "Södra halvklotet — säsongsfönstren speglas",
|
||||
"home_trait_freeze": "Frost på vintern",
|
||||
"home_trait_snow": "Snörika vintrar",
|
||||
"home_trait_severe_winter": "Stränga vintrar",
|
||||
"home_trait_damp": "Fuktigt klimat",
|
||||
"home_trait_mediterranean": "Medelhavsklimat",
|
||||
"home_trait_earthquake": "Jordbävningsområde",
|
||||
"home_trait_hot_summer": "Varma somrar",
|
||||
"home_trait_hot_humid": "Varmt och fuktigt",
|
||||
"home_trait_hot_dry": "Varmt och torrt",
|
||||
"home_trait_termites": "Termitområde",
|
||||
"home_trait_wildfire": "Risk för skogsbränder",
|
||||
"home_trait_cyclone": "Orkan-/tyfonområde",
|
||||
"home_trait_tropical": "Tropiskt klimat",
|
||||
"home_trait_radon": "Radonmätning rekommenderas",
|
||||
"home_reason_starter": "Grunderna för ditt hem",
|
||||
"home_reason_country": "Vanligt i {country}",
|
||||
"home_reason_feature": "Hittat i ditt hem: {feature}",
|
||||
"templates_recommended_title": "Rekommenderat för ditt hem",
|
||||
"templates_recommended_hint": "Baserat på din bostadsprofil — ändra den i Inställningar.",
|
||||
"templates_legal_hint": "Anvisningar om lagkrav och intervall är vägledning, inte juridisk rådgivning. Reglerna skiljer sig mellan regioner och ändras över tid — lokala föreskrifter och tillverkarens anvisningar gäller i första hand.",
|
||||
"templates_not_typical": "Ovanligt för din bostadstyp",
|
||||
"templates_set_up": "Redan konfigurerad",
|
||||
"worksheet": "Arbetsblad",
|
||||
"worksheet_scan_view": "Skanna för att öppna uppgiften",
|
||||
"worksheet_scan_complete": "Skanna för att slutföra",
|
||||
@@ -901,6 +954,8 @@
|
||||
"adopt_problem_ok": "ok",
|
||||
"adopt_problem_new_object": "(ny)",
|
||||
"adopt_object_hint": "Ge flera sensorer samma objektnamn så blir de ett objekt; skriv ett befintligt objekts namn för att lägga dem där.",
|
||||
"adopt_summary_title": "Vad övertagandet ändrar",
|
||||
"adopt_summary_existing": "”{name}”: uppgifter {before} → {after}",
|
||||
"adopt_problem_adopt": "Anta valda",
|
||||
"adopt_problem_done": "Antog {tasks} problemsensor(er)",
|
||||
"views_label": "Vyer",
|
||||
@@ -973,6 +1028,15 @@
|
||||
"trend_easing": "Rör sig bort från tröskeln",
|
||||
"split_select_hint": "Välj en uppgift för att se detaljerna här",
|
||||
"setups_target_new": "Skapa ny: {name}",
|
||||
"setups_target_match": "{name} — matchar: {reasons}",
|
||||
"setups_reason_model": "modellnummer",
|
||||
"setups_reason_name": "namn",
|
||||
"setups_reason_area": "område",
|
||||
"setups_reason_sibling": "samma modell på en annan enhet",
|
||||
"setups_maybe_covered": "Finns troligen redan som ”{name}”",
|
||||
"setups_already": "Finns redan: {names}",
|
||||
"setups_before_after": "Uppgifter på objektet: {before} → {after}",
|
||||
"setups_new_object_count": "Nytt objekt ”{name}” · uppgifter: {count}",
|
||||
"schedule_preview_title": "Kommande datum",
|
||||
"schedule_preview_ontime": "Förutsatt slutförande i tid.",
|
||||
"schedule_preview_ends": "(serien avslutas)",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"templates_from": "Şablondan",
|
||||
"templates_title": "Bir şablonla başlayın",
|
||||
"templates_task_count": "{n} görev",
|
||||
"templates_task_count_one": "1 görev",
|
||||
"template_created": "Şablondan oluşturuldu",
|
||||
"onboard_hint": "Bakım takibine başlamak için ilk nesnenizi ekleyin.",
|
||||
"edit": "Düzenle",
|
||||
@@ -46,6 +47,7 @@
|
||||
"bulk_delete_objects_confirm": "{n} nesne ve tüm görevleri silinsin mi? Arşivlemek geçmişi korur.",
|
||||
"bulk_objects_deleted": "{n} nesne silindi",
|
||||
"bulk_objects_archived": "{n} nesne arşivlendi",
|
||||
"bulk_failed": "{n} başarısız: {reason}",
|
||||
"completing": "Tamamlanıyor…",
|
||||
"interval": "Aralık",
|
||||
"warning": "Uyarı",
|
||||
@@ -200,8 +202,6 @@
|
||||
"show_all": "Tümünü göster",
|
||||
"cost_duration_chart": "Maliyet ve Süre",
|
||||
"installed": "Kurulum",
|
||||
"confirm_delete_object": "Bu nesne ve tüm görevleri silinsin mi?",
|
||||
"confirm_delete_task": "Bu görev silinsin mi?",
|
||||
"min": "Min",
|
||||
"max": "Maks",
|
||||
"save": "Kaydet",
|
||||
@@ -243,7 +243,6 @@
|
||||
"sort_task_name": "Görev adı",
|
||||
"all_objects": "Tüm nesneler",
|
||||
"all_parts": "Tüm parçalar",
|
||||
"tasks_lower": "görev",
|
||||
"no_tasks_yet": "Henüz görev yok",
|
||||
"add_first_task": "İlk görevi ekle",
|
||||
"trigger_configuration": "Tetikleyici Yapılandırması",
|
||||
@@ -300,6 +299,7 @@
|
||||
"tab_calendar": "Takvim",
|
||||
"cal_no_events": "Bakım yok",
|
||||
"cal_every_n_days": "her {n} günde bir",
|
||||
"cal_every_day": "her gün",
|
||||
"cal_source_time": "Zamana dayalı",
|
||||
"cal_source_time_adaptive": "Zamana dayalı (uyarlanabilir)",
|
||||
"cal_source_sensor": "Sensöre dayalı",
|
||||
@@ -691,7 +691,6 @@
|
||||
"budget_open_panel": "Panelde aç",
|
||||
"groups_empty": "Henüz grup yok.",
|
||||
"group_new_placeholder": "Grup ekle…",
|
||||
"group_delete_confirm": "\"{name}\" grubu silinsin mi?",
|
||||
"groups_manage_tasks": "Görev atamalarını yönet…",
|
||||
"groups_open_panel": "Panelde aç",
|
||||
"unassigned": "Atanmamış",
|
||||
@@ -770,6 +769,7 @@
|
||||
"doc_delete_confirm": "\"{name}\" silinsin mi?",
|
||||
"doc_too_large": "Dosya çok büyük (en fazla 25 MB).",
|
||||
"doc_upload_failed": "Yükleme başarısız oldu.",
|
||||
"docs_archive_too_large": "Arşiv çok büyük (en fazla {max}).",
|
||||
"completion_photos_optional": "Tamamlama fotoğrafları (isteğe bağlı)",
|
||||
"completion_photos": "Tamamlama fotoğrafları",
|
||||
"choose_photos": "Fotoğraf seç",
|
||||
@@ -797,7 +797,11 @@
|
||||
"doc_camera": "Fotoğraf çek",
|
||||
"camera_capture_shoot": "Çek",
|
||||
"camera_switch_lens": "Kamerayı değiştir",
|
||||
"camera_switch_failed": "Başka bir kamera yanıt vermedi — bu telefon yalnızca birini açıyor. Diğer lens için dosya seçiciyi kullanın.",
|
||||
"camera_switching": "Kamera değiştiriliyor…",
|
||||
"camera_not_answered": "Bu kamera yanıt vermedi. Sonrakini denemek için değiştir düğmesine tekrar dokunun veya dosya seçiciyi kullanın.",
|
||||
"camera_diagnostics": "Hata raporu için ayrıntılar",
|
||||
"camera_copy": "Kopyala",
|
||||
"camera_copied": "Kopyalandı",
|
||||
"doc_drop_hint": "Dosyaları buraya bırakın",
|
||||
"doc_task_none": "Bu göreve bağlı belge yok.",
|
||||
"doc_link_existing": "Belge bağla…",
|
||||
@@ -850,6 +854,55 @@
|
||||
"reading_below_last": "Son okumadan ({value}) düşük",
|
||||
"settings_templates_label": "Şablon galerisi",
|
||||
"settings_templates_hint": "Asla ihtiyaç duymayacağınız şablonların işaretini kaldırın — \"Şablondan\" seçicilerinden (panel ve yapılandırma akışı) kaybolurlar. Başka hiçbir şey değişmez; istediğiniz zaman yeniden etkinleştirebilirsiniz.",
|
||||
"home_profile_title": "Konut profili",
|
||||
"home_profile_hint": "İklim ve yarımküreye göre şablon önerir ve mevsimsel pencereleri ayarlar. Her şey katlarınız, alanlarınız ve ev konumunuzdan yerel olarak belirlenir.",
|
||||
"home_type_label": "Konut türü",
|
||||
"home_type_auto": "Otomatik ({detected})",
|
||||
"home_region_label": "Eyalet, il veya bölge",
|
||||
"home_region_none": "bulunamadı",
|
||||
"home_region_hint": "Bazı kurallar ülke içinde değişir: bir eyalette yıllık araç muayenesi vardır, komşusunda yoktur. Evinizin konumundan belirlendi.",
|
||||
"home_dwelling_house": "Müstakil ev",
|
||||
"home_dwelling_apartment": "Daire",
|
||||
"home_dwelling_unknown": "tanınmadı",
|
||||
"home_detected_from": "Şunlardan anlaşıldı: {reasons}",
|
||||
"home_reason_floors": "birden fazla kat",
|
||||
"home_reason_basement": "bodrum",
|
||||
"home_reason_garden": "bahçe",
|
||||
"home_reason_shed": "kulübe",
|
||||
"home_reason_driveway": "garaj yolu",
|
||||
"home_reason_garage": "garaj",
|
||||
"home_reason_attic": "çatı arası",
|
||||
"home_reason_lawn_mower": "çim biçme makinesi",
|
||||
"home_reason_gate": "bahçe kapısı",
|
||||
"home_reason_balcony": "balkon",
|
||||
"home_reason_small_home": "tek katta az oda",
|
||||
"home_reason_ups": "kesintisiz güç kaynağı",
|
||||
"home_climate_label": "İklim",
|
||||
"home_climate_value": "Köppen {koppen} · en soğuk ay {cold} °C · en sıcak ay {warm} °C",
|
||||
"home_climate_unknown": "Home Assistant'ta ev konumu ayarlanmamış",
|
||||
"home_hemisphere_south": "Güney yarımküre — mevsimsel pencereler tersine çevrilir",
|
||||
"home_trait_freeze": "Kışın don",
|
||||
"home_trait_snow": "Karlı kışlar",
|
||||
"home_trait_severe_winter": "Sert kışlar",
|
||||
"home_trait_damp": "Nemli iklim",
|
||||
"home_trait_mediterranean": "Akdeniz iklimi",
|
||||
"home_trait_earthquake": "Deprem bölgesi",
|
||||
"home_trait_hot_summer": "Sıcak yazlar",
|
||||
"home_trait_hot_humid": "Sıcak ve nemli",
|
||||
"home_trait_hot_dry": "Sıcak ve kuru",
|
||||
"home_trait_termites": "Termit bölgesi",
|
||||
"home_trait_wildfire": "Orman yangını riski",
|
||||
"home_trait_cyclone": "Kasırga / tayfun bölgesi",
|
||||
"home_trait_tropical": "Tropikal iklim",
|
||||
"home_trait_radon": "Radon ölçümü önerilir",
|
||||
"home_reason_starter": "Eviniz için temel bakım",
|
||||
"home_reason_country": "{country} için yaygın",
|
||||
"home_reason_feature": "Evinizde bulundu: {feature}",
|
||||
"templates_recommended_title": "Eviniz için önerilenler",
|
||||
"templates_recommended_hint": "Konut profilinize göre — Ayarlar'dan değiştirebilirsiniz.",
|
||||
"templates_legal_hint": "Yasal yükümlülükler ve süreler hakkındaki notlar yol gösterici niteliktedir, hukuki tavsiye değildir. Kurallar bölgeye göre değişir ve zamanla güncellenir — yerel mevzuat ve üreticinin talimatları esastır.",
|
||||
"templates_not_typical": "Konut türünüz için alışılmadık",
|
||||
"templates_set_up": "Zaten kurulu",
|
||||
"worksheet": "Çalışma sayfası",
|
||||
"worksheet_scan_view": "Görevi açmak için tarayın",
|
||||
"worksheet_scan_complete": "Tamamlamak için tarayın",
|
||||
@@ -901,6 +954,8 @@
|
||||
"adopt_problem_ok": "tamam",
|
||||
"adopt_problem_new_object": "(yeni)",
|
||||
"adopt_object_hint": "Birden çok sensöre aynı nesne adını verin, tek nesne olurlar; mevcut bir nesnenin adını yazarsanız oraya eklenirler.",
|
||||
"adopt_summary_title": "Devralmanın değiştirecekleri",
|
||||
"adopt_summary_existing": "\"{name}\": görevler {before} → {after}",
|
||||
"adopt_problem_adopt": "Seçilenleri devral",
|
||||
"adopt_problem_done": "{tasks} sorun sensörü devralındı",
|
||||
"views_label": "Görünümler",
|
||||
@@ -973,6 +1028,15 @@
|
||||
"trend_easing": "Eşikten uzaklaşıyor",
|
||||
"split_select_hint": "Ayrıntıları burada görmek için bir görev seçin",
|
||||
"setups_target_new": "Yeni oluştur: {name}",
|
||||
"setups_target_match": "{name} — eşleşiyor: {reasons}",
|
||||
"setups_reason_model": "model numarası",
|
||||
"setups_reason_name": "ad",
|
||||
"setups_reason_area": "alan",
|
||||
"setups_reason_sibling": "başka bir cihazda aynı model",
|
||||
"setups_maybe_covered": "Muhtemelen \"{name}\" olarak zaten var",
|
||||
"setups_already": "Zaten var: {names}",
|
||||
"setups_before_after": "Nesnenin görevleri: {before} → {after}",
|
||||
"setups_new_object_count": "Yeni nesne \"{name}\" · görevler: {count}",
|
||||
"schedule_preview_title": "Sonraki tarihler",
|
||||
"schedule_preview_ontime": "Zamanında tamamlama varsayılarak.",
|
||||
"schedule_preview_ends": "(seri sona erer)",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"templates_from": "Із шаблону",
|
||||
"templates_title": "Почати із шаблону",
|
||||
"templates_task_count": "{n} завдань",
|
||||
"templates_task_count_one": "1 завдання",
|
||||
"template_created": "Створено із шаблону",
|
||||
"onboard_hint": "Додайте перший об'єкт, щоб відстежувати обслуговування.",
|
||||
"edit": "Редагувати",
|
||||
@@ -44,6 +45,7 @@
|
||||
"bulk_delete_objects_confirm": "Видалити {n} об'єктів з усіма їхніми завданнями? Архівування натомість збереже історію.",
|
||||
"bulk_objects_deleted": "Видалено об'єктів: {n}",
|
||||
"bulk_objects_archived": "Архівовано об'єктів: {n}",
|
||||
"bulk_failed": "Не вдалося: {n} — {reason}",
|
||||
"completing": "Виконується…",
|
||||
"interval": "Інтервал",
|
||||
"warning": "Попередження",
|
||||
@@ -198,8 +200,6 @@
|
||||
"show_all": "Показати всі",
|
||||
"cost_duration_chart": "Вартість і тривалість",
|
||||
"installed": "Встановлено",
|
||||
"confirm_delete_object": "Видалити цей об'єкт і всі його завдання?",
|
||||
"confirm_delete_task": "Видалити це завдання?",
|
||||
"min": "Мін",
|
||||
"max": "Макс",
|
||||
"save": "Зберегти",
|
||||
@@ -241,7 +241,6 @@
|
||||
"sort_task_name": "Назва завдання",
|
||||
"all_objects": "Всі об'єкти",
|
||||
"all_parts": "Усі деталі",
|
||||
"tasks_lower": "завдань",
|
||||
"no_tasks_yet": "Завдань ще немає",
|
||||
"add_first_task": "Додати перше завдання",
|
||||
"trigger_configuration": "Налаштування тригера",
|
||||
@@ -298,6 +297,7 @@
|
||||
"tab_calendar": "Календар",
|
||||
"cal_no_events": "Без обслуговування",
|
||||
"cal_every_n_days": "кожні {n} днів",
|
||||
"cal_every_day": "щодня",
|
||||
"cal_source_time": "За часом",
|
||||
"cal_source_time_adaptive": "За часом (адаптивно)",
|
||||
"cal_source_sensor": "За датчиком",
|
||||
@@ -713,7 +713,6 @@
|
||||
"budget_open_panel": "Відкрити на панелі",
|
||||
"groups_empty": "Поки немає груп.",
|
||||
"group_new_placeholder": "Додати групу…",
|
||||
"group_delete_confirm": "Видалити групу «{name}»?",
|
||||
"groups_manage_tasks": "Керування призначеннями завдань…",
|
||||
"groups_open_panel": "Відкрити на панелі",
|
||||
"on_complete_action_target_hint": "Примітка: домен сутності має відповідати сервісу — напр. 'button.press' працює лише з button.*, 'counter.increment' лише з counter.*, 'input_button.press' лише з input_button.* тощо. У разі невідповідності дія мовчки не виконається (HA запише в журнал 'Referenced entities ... missing or not currently available').",
|
||||
@@ -764,6 +763,7 @@
|
||||
"doc_delete_confirm": "Видалити «{name}»?",
|
||||
"doc_too_large": "Файл завеликий (макс. 25 МБ).",
|
||||
"doc_upload_failed": "Не вдалося завантажити.",
|
||||
"docs_archive_too_large": "Архів завеликий (макс. {max}).",
|
||||
"completion_photos_optional": "Фото виконання (необов'язково)",
|
||||
"completion_photos": "Фото виконання",
|
||||
"choose_photos": "Вибрати фото",
|
||||
@@ -791,7 +791,11 @@
|
||||
"doc_camera": "Зробити фото",
|
||||
"camera_capture_shoot": "Зняти",
|
||||
"camera_switch_lens": "Змінити камеру",
|
||||
"camera_switch_failed": "Інша камера не відповіла — цей телефон надає лише одну. Для іншого об'єктива скористайтеся вибором файлу.",
|
||||
"camera_switching": "Перемикання камери…",
|
||||
"camera_not_answered": "Ця камера не відповіла. Натисніть перемикання ще раз, щоб спробувати наступну, або виберіть файл.",
|
||||
"camera_diagnostics": "Подробиці для звіту про помилку",
|
||||
"camera_copy": "Копіювати",
|
||||
"camera_copied": "Скопійовано",
|
||||
"doc_drop_hint": "Перетягніть файли сюди",
|
||||
"doc_task_none": "Немає документів, пов'язаних із цим завданням.",
|
||||
"doc_link_existing": "Пов'язати документ…",
|
||||
@@ -844,6 +848,55 @@
|
||||
"reading_below_last": "Менше за попереднє показання ({value})",
|
||||
"settings_templates_label": "Галерея шаблонів",
|
||||
"settings_templates_hint": "Зніміть позначку з шаблонів, які вам не потрібні — вони зникнуть із вибору «З шаблону» (панель і config flow). Більше нічого не змінюється; можна ввімкнути знову будь-коли.",
|
||||
"home_profile_title": "Профіль житла",
|
||||
"home_profile_hint": "Рекомендує шаблони та задає сезонні вікна відповідно до вашого клімату й півкулі. Усе визначається локально за вашими поверхами, зонами та розташуванням дому.",
|
||||
"home_type_label": "Тип житла",
|
||||
"home_type_auto": "Автоматично ({detected})",
|
||||
"home_region_label": "Штат, провінція або регіон",
|
||||
"home_region_none": "не знайдено",
|
||||
"home_region_hint": "Деякі правила відрізняються в межах країни: в одному штаті щорічний техогляд авто, у сусідньому — жодного. Визначено за розташуванням дому.",
|
||||
"home_dwelling_house": "Будинок",
|
||||
"home_dwelling_apartment": "Квартира",
|
||||
"home_dwelling_unknown": "не визначено",
|
||||
"home_detected_from": "Визначено за: {reasons}",
|
||||
"home_reason_floors": "кілька поверхів",
|
||||
"home_reason_basement": "підвал",
|
||||
"home_reason_garden": "сад",
|
||||
"home_reason_shed": "сарай",
|
||||
"home_reason_driveway": "під'їзна доріжка",
|
||||
"home_reason_garage": "гараж",
|
||||
"home_reason_attic": "горище",
|
||||
"home_reason_lawn_mower": "газонокосарка",
|
||||
"home_reason_gate": "ворота",
|
||||
"home_reason_balcony": "балкон",
|
||||
"home_reason_small_home": "мало кімнат на одному поверсі",
|
||||
"home_reason_ups": "ДБЖ",
|
||||
"home_climate_label": "Клімат",
|
||||
"home_climate_value": "Кеппен {koppen} · найхолодніший місяць {cold} °C · найтепліший місяць {warm} °C",
|
||||
"home_climate_unknown": "У Home Assistant не задано розташування дому",
|
||||
"home_hemisphere_south": "Південна півкуля — сезонні вікна дзеркально зсунуто",
|
||||
"home_trait_freeze": "Морози взимку",
|
||||
"home_trait_snow": "Сніжні зими",
|
||||
"home_trait_severe_winter": "Суворі зими",
|
||||
"home_trait_damp": "Вологий клімат",
|
||||
"home_trait_mediterranean": "Середземноморський клімат",
|
||||
"home_trait_earthquake": "Сейсмонебезпечний район",
|
||||
"home_trait_hot_summer": "Спекотне літо",
|
||||
"home_trait_hot_humid": "Спекотно й волого",
|
||||
"home_trait_hot_dry": "Спекотно й сухо",
|
||||
"home_trait_termites": "Район термітів",
|
||||
"home_trait_wildfire": "Ризик лісових пожеж",
|
||||
"home_trait_cyclone": "Район ураганів / тайфунів",
|
||||
"home_trait_tropical": "Тропічний клімат",
|
||||
"home_trait_radon": "Рекомендовано вимірювання радону",
|
||||
"home_reason_starter": "Основи для вашого дому",
|
||||
"home_reason_country": "Поширено: {country}",
|
||||
"home_reason_feature": "Знайдено у вашому домі: {feature}",
|
||||
"templates_recommended_title": "Рекомендовано для вашого дому",
|
||||
"templates_recommended_hint": "На основі профілю житла — можна змінити в налаштуваннях.",
|
||||
"templates_legal_hint": "Примітки щодо обов'язкових вимог і строків мають довідковий характер і не є юридичною консультацією. Правила відрізняються залежно від регіону та змінюються — пріоритет мають місцеві норми й інструкції виробника.",
|
||||
"templates_not_typical": "Нетипово для вашого типу житла",
|
||||
"templates_set_up": "Уже налаштовано",
|
||||
"worksheet": "Робочий аркуш",
|
||||
"worksheet_scan_view": "Скануйте, щоб відкрити задачу",
|
||||
"worksheet_scan_complete": "Скануйте для виконання",
|
||||
@@ -901,6 +954,8 @@
|
||||
"adopt_problem_ok": "норма",
|
||||
"adopt_problem_new_object": "(новий)",
|
||||
"adopt_object_hint": "Дайте кільком сенсорам однакову назву об'єкта — вони стануть одним об'єктом; введіть назву наявного об'єкта, щоб додати їх туди.",
|
||||
"adopt_summary_title": "Що змінить додавання",
|
||||
"adopt_summary_existing": "«{name}»: завдання {before} → {after}",
|
||||
"adopt_problem_adopt": "Прийняти вибрані",
|
||||
"adopt_problem_done": "Прийнято сенсорів проблем: {tasks}",
|
||||
"views_label": "Подання",
|
||||
@@ -973,6 +1028,15 @@
|
||||
"trend_easing": "Віддаляється від порога",
|
||||
"split_select_hint": "Виберіть завдання, щоб побачити тут його деталі",
|
||||
"setups_target_new": "Створити новий: {name}",
|
||||
"setups_target_match": "{name} — збігається: {reasons}",
|
||||
"setups_reason_model": "номер моделі",
|
||||
"setups_reason_name": "назва",
|
||||
"setups_reason_area": "зона",
|
||||
"setups_reason_sibling": "та сама модель на іншому пристрої",
|
||||
"setups_maybe_covered": "Імовірно, вже є як «{name}»",
|
||||
"setups_already": "Уже є: {names}",
|
||||
"setups_before_after": "Завдання об'єкта: {before} → {after}",
|
||||
"setups_new_object_count": "Новий об'єкт «{name}» · завдання: {count}",
|
||||
"schedule_preview_title": "Найближчі дати",
|
||||
"schedule_preview_ontime": "За умови вчасного виконання.",
|
||||
"schedule_preview_ends": "(серія завершується)",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"templates_from": "从模板",
|
||||
"templates_title": "从模板开始",
|
||||
"templates_task_count": "{n} 个任务",
|
||||
"templates_task_count_one": "1 个任务",
|
||||
"template_created": "已从模板创建",
|
||||
"onboard_hint": "添加第一个对象以开始跟踪维护。",
|
||||
"edit": "编辑",
|
||||
@@ -46,6 +47,7 @@
|
||||
"bulk_delete_objects_confirm": "删除 {n} 个对象及其全部任务?归档则会保留其历史记录。",
|
||||
"bulk_objects_deleted": "已删除 {n} 个对象",
|
||||
"bulk_objects_archived": "已归档 {n} 个对象",
|
||||
"bulk_failed": "{n} 项失败:{reason}",
|
||||
"completing": "正在完成…",
|
||||
"interval": "间隔",
|
||||
"warning": "预警",
|
||||
@@ -200,8 +202,6 @@
|
||||
"show_all": "全部显示",
|
||||
"cost_duration_chart": "成本与耗时",
|
||||
"installed": "已安装",
|
||||
"confirm_delete_object": "确定删除此维护项及其所有任务吗?",
|
||||
"confirm_delete_task": "确定删除此任务吗?",
|
||||
"min": "最小",
|
||||
"max": "最大",
|
||||
"save": "保存",
|
||||
@@ -243,7 +243,6 @@
|
||||
"sort_task_name": "任务名称",
|
||||
"all_objects": "所有维护项",
|
||||
"all_parts": "所有配件",
|
||||
"tasks_lower": "任务",
|
||||
"no_tasks_yet": "尚无任务",
|
||||
"add_first_task": "添加首个任务",
|
||||
"trigger_configuration": "触发器配置",
|
||||
@@ -300,6 +299,7 @@
|
||||
"tab_calendar": "日历",
|
||||
"cal_no_events": "无维护事项",
|
||||
"cal_every_n_days": "每 {n} 天",
|
||||
"cal_every_day": "每天",
|
||||
"cal_source_time": "基于时间",
|
||||
"cal_source_time_adaptive": "基于时间 (自适应)",
|
||||
"cal_source_sensor": "基于传感器",
|
||||
@@ -685,7 +685,6 @@
|
||||
"budget_open_panel": "在面板中打开",
|
||||
"groups_empty": "尚无分组。",
|
||||
"group_new_placeholder": "添加分组…",
|
||||
"group_delete_confirm": "确定删除分组 \"{name}\" 吗?",
|
||||
"groups_manage_tasks": "管理任务分配…",
|
||||
"groups_open_panel": "在面板中打开",
|
||||
"unassigned": "未分配",
|
||||
@@ -764,6 +763,7 @@
|
||||
"doc_delete_confirm": "删除“{name}”?",
|
||||
"doc_too_large": "文件过大(最大 25 MB)。",
|
||||
"doc_upload_failed": "上传失败。",
|
||||
"docs_archive_too_large": "压缩包过大(最大 {max})。",
|
||||
"completion_photos_optional": "完成照片(可选)",
|
||||
"completion_photos": "完成照片",
|
||||
"choose_photos": "选择照片",
|
||||
@@ -791,7 +791,11 @@
|
||||
"doc_camera": "拍照",
|
||||
"camera_capture_shoot": "拍摄",
|
||||
"camera_switch_lens": "切换摄像头",
|
||||
"camera_switch_failed": "没有其他摄像头响应——这部手机只开放一个。另一个镜头请使用文件选择器。",
|
||||
"camera_switching": "正在切换摄像头…",
|
||||
"camera_not_answered": "此摄像头没有响应。再次点击切换以尝试下一个,或使用文件选择器。",
|
||||
"camera_diagnostics": "用于错误报告的详细信息",
|
||||
"camera_copy": "复制",
|
||||
"camera_copied": "已复制",
|
||||
"doc_drop_hint": "将文件拖放到此处",
|
||||
"doc_task_none": "没有与此任务关联的文档。",
|
||||
"doc_link_existing": "关联文档…",
|
||||
@@ -844,6 +848,55 @@
|
||||
"reading_below_last": "低于上次读数({value})",
|
||||
"settings_templates_label": "模板库",
|
||||
"settings_templates_hint": "取消勾选你永远不需要的模板——它们会从\"从模板\"选择器(面板和配置流程)中消失。其他一切不变;可随时重新启用。",
|
||||
"home_profile_title": "住宅概况",
|
||||
"home_profile_hint": "根据您的气候和所在半球推荐模板并设置季节窗口。所有信息均根据您的楼层、区域和住宅位置在本地得出。",
|
||||
"home_type_label": "住宅类型",
|
||||
"home_type_auto": "自动({detected})",
|
||||
"home_region_label": "州、省或地区",
|
||||
"home_region_none": "未找到",
|
||||
"home_region_hint": "有些规定在同一国家内也不同——某个州每年检验车辆,相邻的州则不需要。根据住址位置判断。",
|
||||
"home_dwelling_house": "独栋住宅",
|
||||
"home_dwelling_apartment": "公寓",
|
||||
"home_dwelling_unknown": "未识别",
|
||||
"home_detected_from": "判断依据:{reasons}",
|
||||
"home_reason_floors": "多个楼层",
|
||||
"home_reason_basement": "地下室",
|
||||
"home_reason_garden": "花园",
|
||||
"home_reason_shed": "工具棚",
|
||||
"home_reason_driveway": "车道",
|
||||
"home_reason_garage": "车库",
|
||||
"home_reason_attic": "阁楼",
|
||||
"home_reason_lawn_mower": "割草机",
|
||||
"home_reason_gate": "大门",
|
||||
"home_reason_balcony": "阳台",
|
||||
"home_reason_small_home": "单层且房间较少",
|
||||
"home_reason_ups": "UPS 不间断电源",
|
||||
"home_climate_label": "气候",
|
||||
"home_climate_value": "柯本 {koppen} · 最冷月 {cold} °C · 最热月 {warm} °C",
|
||||
"home_climate_unknown": "Home Assistant 中未设置住宅位置",
|
||||
"home_hemisphere_south": "南半球——季节窗口自动对调",
|
||||
"home_trait_freeze": "冬季霜冻",
|
||||
"home_trait_snow": "冬季多雪",
|
||||
"home_trait_severe_winter": "严冬",
|
||||
"home_trait_damp": "潮湿气候",
|
||||
"home_trait_mediterranean": "地中海气候",
|
||||
"home_trait_earthquake": "地震多发地区",
|
||||
"home_trait_hot_summer": "夏季炎热",
|
||||
"home_trait_hot_humid": "湿热",
|
||||
"home_trait_hot_dry": "炎热干燥",
|
||||
"home_trait_termites": "白蚁多发地区",
|
||||
"home_trait_wildfire": "山火风险",
|
||||
"home_trait_cyclone": "飓风/台风地区",
|
||||
"home_trait_tropical": "热带气候",
|
||||
"home_trait_radon": "建议检测氡气",
|
||||
"home_reason_starter": "住宅基础项目",
|
||||
"home_reason_country": "在{country}很常见",
|
||||
"home_reason_feature": "在您的住宅中发现:{feature}",
|
||||
"templates_recommended_title": "为您的住宅推荐",
|
||||
"templates_recommended_hint": "基于您的住宅概况——可在设置中调整。",
|
||||
"templates_legal_hint": "有关法定义务和期限的说明仅供参考,不构成法律建议。各地规定不同且会变化——请以当地法规和制造商说明为准。",
|
||||
"templates_not_typical": "对您的住宅类型不常见",
|
||||
"templates_set_up": "已设置",
|
||||
"worksheet": "工作单",
|
||||
"worksheet_scan_view": "扫码打开任务",
|
||||
"worksheet_scan_complete": "扫码完成",
|
||||
@@ -901,6 +954,8 @@
|
||||
"adopt_problem_ok": "正常",
|
||||
"adopt_problem_new_object": "(新建)",
|
||||
"adopt_object_hint": "给多个传感器相同的对象名称,它们就会合并为一个对象;输入已有对象的名称即可加入该对象。",
|
||||
"adopt_summary_title": "采用后会发生的变化",
|
||||
"adopt_summary_existing": "“{name}”:任务 {before} → {after}",
|
||||
"adopt_problem_adopt": "采用所选",
|
||||
"adopt_problem_done": "已采用 {tasks} 个问题传感器",
|
||||
"views_label": "视图",
|
||||
@@ -973,6 +1028,15 @@
|
||||
"trend_easing": "正远离阈值",
|
||||
"split_select_hint": "选择一个任务,在此查看详情",
|
||||
"setups_target_new": "新建:{name}",
|
||||
"setups_target_match": "{name} — 匹配:{reasons}",
|
||||
"setups_reason_model": "型号",
|
||||
"setups_reason_name": "名称",
|
||||
"setups_reason_area": "区域",
|
||||
"setups_reason_sibling": "另一台设备上的相同型号",
|
||||
"setups_maybe_covered": "可能已存在为“{name}”",
|
||||
"setups_already": "已存在:{names}",
|
||||
"setups_before_after": "对象的任务:{before} → {after}",
|
||||
"setups_new_object_count": "新对象“{name}” · 任务:{count}",
|
||||
"schedule_preview_title": "下次日期",
|
||||
"schedule_preview_ontime": "假设按时完成。",
|
||||
"schedule_preview_ends": "(系列结束)",
|
||||
|
||||
@@ -32,12 +32,17 @@ import {
|
||||
buildCalendarBuckets,
|
||||
buildPastBuckets,
|
||||
isoDateLocal,
|
||||
pastHistoryGaps,
|
||||
pastHistoryKey,
|
||||
type CalendarEvent,
|
||||
type HistoryEntryShape,
|
||||
} from "./helpers/calendar-bucket";
|
||||
import { calendarStyles } from "./calendar-styles";
|
||||
import { syncLocaleFromHass, sharedStyles, currencySymbolOf, t, ensureLocale, isLocaleLoaded, setProfilePrefs, formatDueDays, formatWeekday, formatMonth, langOf, formatCost, syncCurrencyDecimals} from "./styles";
|
||||
import { syncLocaleFromHass, sharedStyles, currencySymbolOf, t, ensureLocale, isLocaleLoaded, setProfilePrefs, formatDueDays, formatInterval, formatWeekday, formatMonth, langOf, formatCost, syncCurrencyDecimals} from "./styles";
|
||||
import { registerCustomCard } from "./helpers/register-card";
|
||||
import { loadHistoryEntryDraft } from "./helpers/history-draft";
|
||||
import { canWrite } from "./helpers/permissions";
|
||||
import { fetchSettingsOnce } from "./helpers/settings-cache";
|
||||
import { openHistoryEditDialog, openTaskQuickActions } from "./dialog-mount";
|
||||
import type {
|
||||
HomeAssistant,
|
||||
@@ -77,6 +82,11 @@ export class MaintenanceCalendarCard extends LitElement {
|
||||
// 2+ configured object_filter values — restricts the card to this set.
|
||||
private _configuredObjects: string[] = [];
|
||||
@state() private _unsub: (() => void) | null = null;
|
||||
/** Past mode: full histories of the tasks whose list payload (last 20
|
||||
* entries) may not reach back over the whole window. */
|
||||
@state() private _pastHistory: Record<string, HistoryEntryShape[]> = {};
|
||||
private _pastHistorySig = "";
|
||||
private _pastSeq = 0;
|
||||
|
||||
private _dataLoaded = false;
|
||||
private _lastConnection: unknown = null;
|
||||
@@ -128,8 +138,26 @@ export class MaintenanceCalendarCard extends LitElement {
|
||||
return langOf(this.hass);
|
||||
}
|
||||
|
||||
/** An entry edited or deleted anywhere on the page (the history-edit
|
||||
* dialog's composed event reaches window). An edit of an entry OLDER than
|
||||
* the listed window leaves the list payload — and so the refetch
|
||||
* signature — untouched, so a fetched full history of that task is
|
||||
* reloaded here (bug audit 2026-09-26 #2). */
|
||||
private readonly _onHistorySaved = (ev: Event): void => {
|
||||
const d = (ev as CustomEvent<{ entry_id?: string; task_id?: string }>).detail;
|
||||
if (!d?.entry_id || !d.task_id || !(pastHistoryKey(d.entry_id, d.task_id) in this._pastHistory)) return;
|
||||
this._pastHistorySig = "";
|
||||
void this._loadPastHistories();
|
||||
};
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
window.addEventListener("history-entry-saved", this._onHistorySaved);
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener("history-entry-saved", this._onHistorySaved);
|
||||
if (this._unsub) {
|
||||
try { this._unsub(); } catch { /* ignore */ }
|
||||
this._unsub = null;
|
||||
@@ -141,6 +169,9 @@ export class MaintenanceCalendarCard extends LitElement {
|
||||
updated(changedProps: Map<string, unknown>): void {
|
||||
super.updated(changedProps);
|
||||
syncLocaleFromHass(this, changedProps);
|
||||
if (this.hass && this._pastDays > 0 && (changedProps.has("_objects") || changedProps.has("_pastDays"))) {
|
||||
void this._loadPastHistories();
|
||||
}
|
||||
if (changedProps.has("hass") && this.hass) {
|
||||
if (!this._dataLoaded) {
|
||||
this._dataLoaded = true;
|
||||
@@ -177,6 +208,33 @@ export class MaintenanceCalendarCard extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch the full history of every task `pastHistoryGaps` names (refetch
|
||||
* only when that set or its entry counts change); only the newest round
|
||||
* may land. */
|
||||
private async _loadPastHistories(): Promise<void> {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const gaps = pastHistoryGaps(this._objects, today, this._pastDays || 30);
|
||||
const sig = gaps.map((g) => g.sig).join("|");
|
||||
if (sig === this._pastHistorySig) return;
|
||||
this._pastHistorySig = sig;
|
||||
const seq = ++this._pastSeq;
|
||||
const results = await Promise.all(gaps.map(async (g) => {
|
||||
try {
|
||||
const r = await this.hass.connection.sendMessagePromise<{ history?: HistoryEntryShape[] }>({
|
||||
type: "maintenance_supporter/task/history",
|
||||
entry_id: g.entryId,
|
||||
task_id: g.taskId,
|
||||
});
|
||||
return [g.key, r.history ?? []] as const;
|
||||
} catch {
|
||||
return null; // degrade to the listed window for this task
|
||||
}
|
||||
}));
|
||||
if (seq !== this._pastSeq) return;
|
||||
this._pastHistory = Object.fromEntries(results.filter((r): r is readonly [string, HistoryEntryShape[]] => r !== null));
|
||||
}
|
||||
|
||||
private async _subscribe(): Promise<void> {
|
||||
try {
|
||||
const unsub = await this.hass.connection.subscribeMessage(
|
||||
@@ -209,6 +267,10 @@ export class MaintenanceCalendarCard extends LitElement {
|
||||
void this._openHistoryEntry(ev);
|
||||
return;
|
||||
}
|
||||
this._openTask(ev);
|
||||
}
|
||||
|
||||
private _openTask(ev: CalendarEvent): void {
|
||||
if (openTaskQuickActions(ev.entry_id, ev.task_id)) return;
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("ll-custom", {
|
||||
@@ -226,6 +288,18 @@ export class MaintenanceCalendarCard extends LitElement {
|
||||
/** Fetch the recorded entry and open the history-edit dialog directly
|
||||
* (mirrors the strategy shim's ll-custom "edit-history" path). */
|
||||
private async _openHistoryEntry(ev: CalendarEvent): Promise<void> {
|
||||
// Editing a history entry is write tier: a household member without
|
||||
// write access gets the task's quick actions (read view) instead of an
|
||||
// editor whose Save the server refuses (bug audit 2026-09-27).
|
||||
try {
|
||||
const settings = await fetchSettingsOnce(this.hass);
|
||||
if (!canWrite(this.hass?.user, settings.access)) {
|
||||
this._openTask(ev);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* settings unavailable — keep the editor; the server still decides */
|
||||
}
|
||||
try {
|
||||
const draft = await loadHistoryEntryDraft(this.hass, ev.entry_id, ev.task_id, ev.history_timestamp!);
|
||||
if (!draft) return;
|
||||
@@ -294,7 +368,7 @@ export class MaintenanceCalendarCard extends LitElement {
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const isPast = this._pastDays > 0;
|
||||
const buckets = isPast
|
||||
? buildPastBuckets(objects, today, this._pastDays, userFilter)
|
||||
? buildPastBuckets(objects, today, this._pastDays, userFilter, this._pastHistory)
|
||||
: buildCalendarBuckets(objects, today, this._windowDays, userFilter);
|
||||
|
||||
const todayIso = isoDateLocal(today);
|
||||
@@ -312,11 +386,15 @@ export class MaintenanceCalendarCard extends LitElement {
|
||||
const overdueLabel = ev.status === "overdue" && ev.days_until_due != null
|
||||
? ` (${formatDueDays(ev.days_until_due, L)})`
|
||||
: "";
|
||||
// Days read "every N days" (the calendar's own wording, singular for
|
||||
// 1); other units go through the shared interval formatter.
|
||||
const recurEvery = ev.projected && ev.interval_days
|
||||
? html`<span class="cal-event-recur">${
|
||||
ev.interval_unit && ev.interval_unit !== "days"
|
||||
? `${ev.interval_days} ${t("unit_" + ev.interval_unit, L)}`
|
||||
: t("cal_every_n_days", L).replace("{n}", String(ev.interval_days))
|
||||
? formatInterval(ev.interval_days, ev.interval_unit, L)
|
||||
: ev.interval_days === 1
|
||||
? t("cal_every_day", L)
|
||||
: t("cal_every_n_days", L).replace("{n}", String(ev.interval_days))
|
||||
}</span>`
|
||||
: nothing;
|
||||
const isSensor = ev.schedule_type === "sensor_based";
|
||||
|
||||
@@ -4,8 +4,7 @@ import { LitElement, html, css, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { t, langOf } from "./styles";
|
||||
import type { HomeAssistant, CardConfig, MaintenanceObjectResponse, SavedView } from "./types";
|
||||
|
||||
const STATUS_KEYS = ["overdue", "triggered", "due_soon", "ok"] as const;
|
||||
import { STATUS_ORDER } from "./status-constants";
|
||||
|
||||
export class MaintenanceSupporterCardEditor extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -159,7 +158,7 @@ export class MaintenanceSupporterCardEditor extends LitElement {
|
||||
<div class="field">
|
||||
<div class="field-label">${t("card_filter_status", L)}</div>
|
||||
<div class="chip-row">
|
||||
${STATUS_KEYS.map((s) => html`
|
||||
${STATUS_ORDER.map((s) => html`
|
||||
<label class="chip ${selectedStatuses.has(s) ? "active" : ""}">
|
||||
<input type="checkbox"
|
||||
.checked=${selectedStatuses.has(s)}
|
||||
|
||||
@@ -20,7 +20,11 @@ import type {
|
||||
import { UserService } from "./user-service";
|
||||
import { personOf, renderPersonAvatar, type PersonDisplay } from "./helpers/person";
|
||||
import { phaseLabel } from "./helpers/phases";
|
||||
import { renderEventTitles } from "./helpers/event-titles";
|
||||
import { buildCompleteDialogArgs, fillAndOpenCompleteDialog } from "./helpers/complete-dialog-args";
|
||||
import { fetchSettingsOnce, FALLBACK_SETTINGS, type SettingsCache } from "./helpers/settings-cache";
|
||||
import { canWrite } from "./helpers/permissions";
|
||||
import { ACTIONABLE_STATUSES, isActionableStatus, statusRank } from "./status-constants";
|
||||
import "./maintenance-card-editor";
|
||||
import "./components/complete-dialog";
|
||||
import type { MaintenanceCompleteDialog } from "./components/complete-dialog";
|
||||
@@ -31,7 +35,6 @@ import {
|
||||
openCreateObjectDialog,
|
||||
openCreateTaskDialog,
|
||||
openTaskQuickActions,
|
||||
getRowActionStyle,
|
||||
} from "./dialog-mount";
|
||||
|
||||
interface CardDoc {
|
||||
@@ -52,6 +55,13 @@ export class MaintenanceSupporterCard extends LitElement {
|
||||
@state() private _config: CardConfig = { type: "custom:maintenance-supporter-card" };
|
||||
/** #145: global "Task row actions" style, resolved once from the settings cache. */
|
||||
@state() private _globalRowStyle = "buttons_compact";
|
||||
/** Household settings (feature switches gate the complete dialog, the
|
||||
* write delegation gates the header's create buttons) — page cache. */
|
||||
@state() private _settings: SettingsCache = FALLBACK_SETTINGS;
|
||||
/** The first object list has arrived (or failed). Until then the card
|
||||
* shows a loading line — it used to flash "No maintenance tasks yet" and
|
||||
* then an unscoped list before the saved view's scope landed. */
|
||||
@state() private _firstLoadDone = false;
|
||||
@state() private _objects: MaintenanceObjectResponse[] = [];
|
||||
@state() private _stats: StatisticsResponse | null = null;
|
||||
@state() private _unsub: (() => void) | null = null;
|
||||
@@ -84,7 +94,7 @@ export class MaintenanceSupporterCard extends LitElement {
|
||||
type: "custom:maintenance-supporter-card",
|
||||
show_header: true,
|
||||
show_actions: true,
|
||||
filter_status: ["overdue", "triggered", "due_soon"],
|
||||
filter_status: [...ACTIONABLE_STATUSES],
|
||||
max_items: 10,
|
||||
};
|
||||
}
|
||||
@@ -164,20 +174,30 @@ export class MaintenanceSupporterCard extends LitElement {
|
||||
}
|
||||
|
||||
private async _loadData(): Promise<void> {
|
||||
// #145: resolve the household's row-action style once (cached settings).
|
||||
void getRowActionStyle(this.hass).then((s) => { this._globalRowStyle = s; }).catch(() => undefined);
|
||||
// #145: resolve the household's row-action style once (cached settings);
|
||||
// the same answer carries the feature switches + write delegation.
|
||||
void fetchSettingsOnce(this.hass).then((s) => {
|
||||
this._settings = s;
|
||||
this._globalRowStyle = s.rowActionStyle;
|
||||
}).catch(() => undefined);
|
||||
// The saved view's scope loads alongside the objects and lands FIRST, so
|
||||
// the first list paint is already scoped — the rows used to flash
|
||||
// unfiltered until the view round-trip finished.
|
||||
const viewScope = this._loadViewFilters();
|
||||
try {
|
||||
const [objResult, statsResult] = await Promise.all([
|
||||
this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/objects", compact: true }),
|
||||
this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/statistics" }),
|
||||
]);
|
||||
await viewScope;
|
||||
this._objects = hydrateObjects((objResult as { objects: MaintenanceObjectResponse[] }).objects);
|
||||
this._stats = statsResult as StatisticsResponse;
|
||||
syncCurrencyDecimals(this._stats.budget);
|
||||
} catch {
|
||||
// WS not available yet
|
||||
}
|
||||
await this._loadViewFilters();
|
||||
await viewScope;
|
||||
this._firstLoadDone = true;
|
||||
}
|
||||
|
||||
/** Display name of the task's responsible user, or "" when the badge must
|
||||
@@ -380,9 +400,8 @@ export class MaintenanceSupporterCard extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
const order: Record<string, number> = { overdue: 0, triggered: 1, due_soon: 2, ok: 3 };
|
||||
tasks.sort((a, b) => {
|
||||
const byStatus = (order[a.task.status] ?? 9) - (order[b.task.status] ?? 9);
|
||||
const byStatus = statusRank(a.task.status) - statusRank(b.task.status);
|
||||
if (byStatus !== 0) return byStatus;
|
||||
// Within a status, soonest-due first; tasks without a due date go last.
|
||||
return (a.task.days_until_due ?? Infinity) - (b.task.days_until_due ?? Infinity);
|
||||
@@ -398,6 +417,43 @@ export class MaintenanceSupporterCard extends LitElement {
|
||||
await this._loadData();
|
||||
};
|
||||
|
||||
/** The row's Complete action — the SAME derivation the panel uses (phase
|
||||
* override, parts incl. shared pools, tag-scan gate, restock default,
|
||||
* checklist ticks, feature gating) through helpers/complete-dialog-args. */
|
||||
private _openComplete(entryId: string, task: MaintenanceTask): void {
|
||||
const dlg = this.shadowRoot!.querySelector<MaintenanceCompleteDialog>("maintenance-complete-dialog")!;
|
||||
fillAndOpenCompleteDialog(
|
||||
dlg,
|
||||
buildCompleteDialogArgs({
|
||||
entryId,
|
||||
taskId: task.id,
|
||||
taskName: task.name,
|
||||
task,
|
||||
objects: this._objects,
|
||||
lang: this._lang,
|
||||
features: this._settings.features,
|
||||
currencySymbol: currencySymbolOf(this._stats?.budget),
|
||||
}),
|
||||
this._lang,
|
||||
);
|
||||
}
|
||||
|
||||
/** Header badges from the LIVE object list — the statistics call ran once
|
||||
* per load, so the subscription updated the rows while the badges kept
|
||||
* the old counts (bug audit 2026-09-26). Same rule as the server's
|
||||
* compute_status_counts: a task counts in the bucket of its status. */
|
||||
private get _headerCounts(): { overdue: number; due_soon: number; triggered: number } {
|
||||
const counts = { overdue: 0, due_soon: 0, triggered: 0 };
|
||||
for (const obj of this._objects) {
|
||||
for (const task of obj.tasks) {
|
||||
if (isActionableStatus(task.status)) {
|
||||
counts[task.status] += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
/** Open the per-task quick-actions dialog (Complete / Skip / Reset / Edit /
|
||||
* QR / Delete) — full per-task panel parity. Mounted on document.body via
|
||||
* the shared dialog-mount helper, so the card works on any dashboard
|
||||
@@ -414,9 +470,11 @@ export class MaintenanceSupporterCard extends LitElement {
|
||||
// setting (any "buttons*" style → labelled HA button, "icons" → icon).
|
||||
const useButtons = (this._config.action_style ?? (this._globalRowStyle === "icons" ? "icons" : "buttons")) === "buttons";
|
||||
const showActions = this._config.show_actions !== false;
|
||||
// Creating objects/tasks is write tier — the panel's canWrite rule.
|
||||
const showCreate = showActions && canWrite(this.hass?.user, this._settings.access);
|
||||
const compact = this._config.compact || false;
|
||||
const tasks = this._flatTasks;
|
||||
const s = this._stats;
|
||||
const s = this._objects.length || this._stats ? this._headerCounts : null;
|
||||
|
||||
return html`
|
||||
<ha-card>
|
||||
@@ -432,7 +490,7 @@ export class MaintenanceSupporterCard extends LitElement {
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
${showActions
|
||||
${showCreate
|
||||
? html`
|
||||
<mwc-icon-button
|
||||
class="hdr-add"
|
||||
@@ -452,7 +510,9 @@ export class MaintenanceSupporterCard extends LitElement {
|
||||
: nothing}
|
||||
</div>
|
||||
</div>
|
||||
${tasks.length === 0
|
||||
${!this._firstLoadDone
|
||||
? html`<div class="card-loading">${t("loading", L)}</div>`
|
||||
: tasks.length === 0
|
||||
? this._objects.some((o) => o.tasks.length > 0)
|
||||
? html`<div class="empty-card">
|
||||
<!-- (#86) tasks exist but none match the filter (default:
|
||||
@@ -473,7 +533,7 @@ export class MaintenanceSupporterCard extends LitElement {
|
||||
<div class="status-dot" style="background: ${STATUS_COLORS[task.status] || "#ccc"}"></div>
|
||||
<div class="task-info">
|
||||
<div class="task-name">
|
||||
${task.name}
|
||||
${task.name}${renderEventTitles(task.next_event_titles)}
|
||||
${(task as any).due_override
|
||||
? html`<ha-icon
|
||||
class="postponed-icon"
|
||||
@@ -530,22 +590,7 @@ export class MaintenanceSupporterCard extends LitElement {
|
||||
title="${t("complete", L)}"
|
||||
@click=${(e: Event) => {
|
||||
e.stopPropagation();
|
||||
const dlg = this.shadowRoot!.querySelector<MaintenanceCompleteDialog>("maintenance-complete-dialog")!;
|
||||
fillAndOpenCompleteDialog(
|
||||
dlg,
|
||||
buildCompleteDialogArgs({
|
||||
entryId: entry_id,
|
||||
taskId: task.id,
|
||||
taskName: task.name,
|
||||
task,
|
||||
objects: this._objects,
|
||||
lang: L,
|
||||
checklist: task.checklist || [],
|
||||
adaptiveEnabled: !!task.adaptive_config?.enabled,
|
||||
currencySymbol: currencySymbolOf(this._stats?.budget),
|
||||
}),
|
||||
L,
|
||||
);
|
||||
this._openComplete(entry_id, task);
|
||||
}}
|
||||
>
|
||||
<ha-icon slot="start" icon="mdi:check"></ha-icon>${t("complete", L)}
|
||||
@@ -559,26 +604,7 @@ export class MaintenanceSupporterCard extends LitElement {
|
||||
@click=${(e: Event) => {
|
||||
// Stop the row's open-task handler from also firing
|
||||
e.stopPropagation();
|
||||
const dlg = this.shadowRoot!.querySelector<MaintenanceCompleteDialog>("maintenance-complete-dialog")!;
|
||||
// The SAME derivation the panel uses (phase
|
||||
// override, parts incl. shared pools, tag-scan
|
||||
// gate, restock default, checklist ticks) — the
|
||||
// card used to forward a subset.
|
||||
fillAndOpenCompleteDialog(
|
||||
dlg,
|
||||
buildCompleteDialogArgs({
|
||||
entryId: entry_id,
|
||||
taskId: task.id,
|
||||
taskName: task.name,
|
||||
task,
|
||||
objects: this._objects,
|
||||
lang: L,
|
||||
checklist: task.checklist || [],
|
||||
adaptiveEnabled: !!task.adaptive_config?.enabled,
|
||||
currencySymbol: currencySymbolOf(this._stats?.budget),
|
||||
}),
|
||||
L,
|
||||
);
|
||||
this._openComplete(entry_id, task);
|
||||
}}
|
||||
>
|
||||
<ha-icon icon="mdi:check"></ha-icon>
|
||||
@@ -636,6 +662,11 @@ export class MaintenanceSupporterCard extends LitElement {
|
||||
.badge.due_soon { background: var(--warning-color, #ff9800); }
|
||||
.badge.triggered { background: #ff5722; }
|
||||
|
||||
.card-loading {
|
||||
padding: 24px 16px;
|
||||
text-align: center;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.empty-card {
|
||||
padding: 24px 16px;
|
||||
text-align: center;
|
||||
|
||||
+5
-3
@@ -30,7 +30,7 @@
|
||||
* On older HA versions the registration is a silent no-op.
|
||||
*/
|
||||
|
||||
import { STATUS_ICONS } from "./status-constants";
|
||||
import { ACTIONABLE_STATUSES, STATUS_ICONS } from "./status-constants";
|
||||
import { loadHistoryEntryDraft } from "./helpers/history-draft";
|
||||
|
||||
interface MaintenanceObjectResp {
|
||||
@@ -150,7 +150,9 @@ const DUE_DATE_VIEWS: Array<{
|
||||
}> = [
|
||||
{
|
||||
title: "Overdue",
|
||||
icon: "mdi:alert-circle",
|
||||
// The shared overdue icon — "mdi:alert-circle" is the DUE-SOON one, the
|
||||
// exact drift status-constants.ts was created to end (DRY audit 2026-09-26).
|
||||
icon: STATUS_ICONS.overdue,
|
||||
path: "overdue",
|
||||
filter: { filter_due_max_days: -1 },
|
||||
matches: (d) => d <= -1,
|
||||
@@ -326,7 +328,7 @@ function overviewView(summaryIds: SummaryEntityIds = {}, counts: SummaryCounts =
|
||||
makeCardSection({
|
||||
type: "custom:maintenance-supporter-card",
|
||||
show_header: false,
|
||||
filter_status: ["overdue", "triggered", "due_soon"],
|
||||
filter_status: [...ACTIONABLE_STATUSES],
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -7,10 +7,10 @@ import { objectRef, parseRef, renderRefChip, taskRef, type HasRef } from "./help
|
||||
import { applySubscriptionEvent, type SubscriptionEvent } from "./helpers/subscription-merge";
|
||||
import { isStaleBundle } from "./helpers/bundle-version";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { syncLocaleFromHass, sharedStyles, STATUS_COLORS, STATUS_ICONS, currencySymbolOf, t, ensureLocale, isLocaleLoaded, formatDate, formatDueDays, formatInterval, formatRecurrence, setProfilePrefs, langOf, formatCost, syncCurrencyDecimals} from "./styles";
|
||||
import { syncLocaleFromHass, sharedStyles, STATUS_COLORS, currencySymbolOf, t, ensureLocale, isLocaleLoaded, formatDate, formatDueDays, formatInterval, formatRecurrence, setProfilePrefs, langOf, formatCost, formatQty, syncCurrencyDecimals} from "./styles";
|
||||
import { OVERVIEW_TABS, type OverviewTab } from "./helpers/overview-tabs";
|
||||
import { LS_KEYS, lsGet, lsSet } from "./helpers/storage-keys";
|
||||
import { openHtmlInNewTab, openSignedDocument, signApiPath } from "./helpers/document-url";
|
||||
import { openHtmlInNewTab, openSignedDocument, preopenTab, signApiPath } from "./helpers/document-url";
|
||||
import { readObjectsCache, writeObjectsCache } from "./helpers/objects-cache";
|
||||
import { hydrateObjects } from "./helpers/hydrate-objects";
|
||||
import { daysProgress } from "./helpers/interval";
|
||||
@@ -21,9 +21,11 @@ import { downloadTextFile } from "./helpers/download";
|
||||
import { renderNotesMarkdown } from "./helpers/notes-markdown";
|
||||
import { buildTaskWorksheetHtml, type WorksheetExcerpt, type WorksheetLabels } from "./helpers/worksheet";
|
||||
import { describePartLink } from "./helpers/shared-parts";
|
||||
import { docDisplayName } from "./helpers/document-categories";
|
||||
import { effectivePhase } from "./helpers/phases";
|
||||
import { renderEventTitles } from "./helpers/event-titles";
|
||||
import { recommendationReason, type HomeProfile, type TemplateRecommendation } from "./helpers/home-profile";
|
||||
import { buildCompleteDialogArgs, fillAndOpenCompleteDialog } from "./helpers/complete-dialog-args";
|
||||
import { describeWsError } from "./ws-errors";
|
||||
import { panelStyles } from "./panel-styles";
|
||||
|
||||
// Global search (#171): what the panel needs to know about a hit — enough
|
||||
@@ -119,10 +121,14 @@ import { renderPersonChip, type PersonDisplay } from "./helpers/person";
|
||||
import "./components/task-detail-view";
|
||||
import { computeWindow, VIRTUAL_MIN_ROWS } from "./helpers/virtual-window";
|
||||
import { INITIAL_STICKY, nextStickyState, stickyStateOnSelect, stickyTop, type StickyState } from "./helpers/sticky-pane";
|
||||
import { invalidateSettingsCache } from "./helpers/settings-cache";
|
||||
import { invalidateSettingsCache, parseSettings, type SettingsWire } from "./helpers/settings-cache";
|
||||
import { canWrite } from "./helpers/permissions";
|
||||
import { renderStatusBadge } from "./renderers/status";
|
||||
import { TOAST_MS, ACTION_TOAST_MS } from "./helpers/toast";
|
||||
import { buildHistoryEntryDraft } from "./helpers/history-draft";
|
||||
import { readingSlotDelta } from "./helpers/reading-slots";
|
||||
import { runWs } from "./helpers/ws-run";
|
||||
import { bulkResultMessage, runWs, runWsEach } from "./helpers/ws-run";
|
||||
import { STATUS_ORDER, statusRank } from "./status-constants";
|
||||
|
||||
type View = "overview" | "object" | "task" | "all_objects" | "all_parts";
|
||||
|
||||
@@ -161,8 +167,10 @@ type GroupByMode = "none" | "area" | "group" | "user" | "object";
|
||||
const SORT_MODES: readonly SortMode[] = ["due_date", "object", "type", "task_name", "area", "assigned_user", "group"];
|
||||
const GROUP_BY_MODES: readonly GroupByMode[] = ["none", "area", "group", "user", "object"];
|
||||
// (OVERVIEW_TABS: imported from helpers/overview-tabs.ts.)
|
||||
/** The dashboard list's status filter options (the <select> in the toolbar). */
|
||||
const STATUS_FILTERS: readonly string[] = ["overdue", "due_soon", "triggered", "ok"];
|
||||
/** The dashboard list's status filter options (the <select> in the toolbar)
|
||||
* — in the urgency order every list sorts by (it offered due soon before
|
||||
* triggered, the one copy out of line; DRY audit 2026-09-26). */
|
||||
const STATUS_FILTERS: readonly string[] = STATUS_ORDER;
|
||||
/** #179: the object page's collapsible sections — the ids saved under
|
||||
* LS_KEYS.objectSections and accepted by the `?section=` deep link. */
|
||||
type ObjectSection = "tasks" | "documents" | "parts" | "history";
|
||||
@@ -363,7 +371,8 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
private _searchSeq = 0;
|
||||
// v2.15.0: template gallery (surfaces the config-flow object templates).
|
||||
@state() private _templateGalleryOpen = false;
|
||||
@state() private _templates: Array<{ id: string; name: string; category: string; tasks: unknown[]; disabled?: boolean }> = [];
|
||||
@state() private _templates: Array<{ id: string; name: string; category: string; tasks: unknown[]; disabled?: boolean } & TemplateRecommendation> = [];
|
||||
@state() private _homeProfile: HomeProfile | null = null;
|
||||
@state() private _templateCategories: Record<string, { icon?: string; [k: string]: unknown }> = {};
|
||||
@state() private _templateBusy = false;
|
||||
// v1.5.0: Calendar tab state
|
||||
@@ -387,7 +396,9 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
* - admins (incl. the owner) always see the full panel
|
||||
* - a non-admin sees the full panel only when operator-write delegation is
|
||||
* enabled AND their ID is in the `admin_panel_user_ids` allowlist
|
||||
* - everyone else sees operator mode (Complete / Skip only)
|
||||
* - everyone else sees operator mode (the household actions only:
|
||||
* Complete / Skip / Reset / Postpone / Snooze — helpers/permissions
|
||||
* HOUSEHOLD_ACTIONS, read tier on the server)
|
||||
*
|
||||
* Delegation defaults OFF, so out of the box every non-admin is read-only.
|
||||
* The switch + allowlist are managed by an admin under Settings → Panel
|
||||
@@ -395,10 +406,11 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
* server mirrors this exact rule in helpers/permissions.user_may_write.
|
||||
*/
|
||||
private get _isOperator(): boolean {
|
||||
const u = this.hass?.user;
|
||||
if (!u) return true; // pre-hass state: render safe default
|
||||
if (u.is_admin) return false;
|
||||
return !(this._operatorWriteEnabled && this._adminPanelUserIds.includes(u.id));
|
||||
// No user yet (pre-hass state) → read-only, the safe default.
|
||||
return !canWrite(this.hass?.user, {
|
||||
operatorWriteEnabled: this._operatorWriteEnabled,
|
||||
operatorIds: this._adminPanelUserIds,
|
||||
});
|
||||
}
|
||||
|
||||
private _popstateHandler = (e: PopStateEvent) => this._onPopState(e);
|
||||
@@ -760,25 +772,17 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
if (budgetResult) { this._budget = budgetResult as BudgetStatus; syncCurrencyDecimals(this._budget); }
|
||||
if (groupsResult) this._groups = (groupsResult as { groups: Record<string, MaintenanceGroup> }).groups || {};
|
||||
if (settingsResult) {
|
||||
const sr = settingsResult as {
|
||||
features: AdvancedFeatures;
|
||||
admin_panel_user_ids?: string[];
|
||||
operator_write_enabled?: boolean;
|
||||
general?: { default_warning_days?: number; row_action_style?: string; row_action_notice_pending?: boolean; ref_numbers_in_lists?: boolean };
|
||||
objects_table_columns?: string[];
|
||||
};
|
||||
this._features = sr.features;
|
||||
this._adminPanelUserIds = sr.admin_panel_user_ids || [];
|
||||
this._operatorWriteEnabled = sr.operator_write_enabled ?? false;
|
||||
const dwd = sr.general?.default_warning_days;
|
||||
if (typeof dwd === "number" && dwd >= 0 && dwd <= 365) {
|
||||
this._defaultWarningDays = dwd;
|
||||
}
|
||||
const ras = sr.general?.row_action_style;
|
||||
this._rowActionStyle = ras === "icons" || ras === "buttons" ? ras : "buttons_compact";
|
||||
this._rowActionNotice = sr.general?.row_action_notice_pending === true;
|
||||
this._refsInLists = sr.general?.ref_numbers_in_lists === true;
|
||||
this._objectsTableColumns = sanitizeColumns(sr.objects_table_columns);
|
||||
// The one settings parser (helpers/settings-cache) — the panel kept its
|
||||
// own copy of the field rules (DRY audit 2026-09-26).
|
||||
const s = parseSettings(settingsResult as SettingsWire);
|
||||
this._features = s.features;
|
||||
this._adminPanelUserIds = [...s.access.operatorIds];
|
||||
this._operatorWriteEnabled = s.access.operatorWriteEnabled;
|
||||
this._defaultWarningDays = s.defaultWarningDays;
|
||||
this._rowActionStyle = s.rowActionStyle;
|
||||
this._rowActionNotice = s.rowActionNoticePending;
|
||||
this._refsInLists = s.refsInLists;
|
||||
this._objectsTableColumns = sanitizeColumns(s.objectsTableColumns);
|
||||
}
|
||||
|
||||
// Fetch mini-sparkline data for overview (non-blocking)
|
||||
@@ -993,8 +997,17 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
}
|
||||
this._showTask(entryId, taskId);
|
||||
if (action === "complete") {
|
||||
// The printed "Complete" QR IS the scan (docs: proof of presence) —
|
||||
// without via_tag_scan a require_tag_scan task refused the very
|
||||
// completion its own sticker opened (bug audit 2026-09-26).
|
||||
requestAnimationFrame(() => {
|
||||
this._openCompleteDialog(entryId, taskId, task.name, this._features.checklists ? task.checklist : undefined, this._features.adaptive && !!task.adaptive_config?.enabled);
|
||||
this._openCompleteDialog(entryId, taskId, task.name, this._features.checklists ? task.checklist : undefined, this._features.adaptive && !!task.adaptive_config?.enabled, { viaTagScan: true });
|
||||
});
|
||||
} else if (action === "skip") {
|
||||
// The settings view prints "Skip" QR codes too; the scan used to
|
||||
// land on the plain task page with no skip prompt.
|
||||
requestAnimationFrame(() => {
|
||||
if (task.allow_skip !== false) void this._promptSkipTask(entryId, taskId);
|
||||
});
|
||||
} else if (action === "quick_complete") {
|
||||
// v1.3.0: silent complete using pre-configured defaults; falls back
|
||||
@@ -1014,9 +1027,17 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
return type === "counter" || type === "state_change";
|
||||
}
|
||||
|
||||
/** Newest request per entity — quick range-chip clicks (7 → 30 → 90) put
|
||||
* several fetches in flight, and a slower OLDER answer landed last and
|
||||
* showed the wrong window under the new chip (bug audit 2026-09-26). */
|
||||
private _detailStatsSeq = new Map<string, number>();
|
||||
|
||||
private async _fetchDetailStats(entityId: string, isCounter: boolean): Promise<void> {
|
||||
if (!this._statsService) return;
|
||||
const seq = (this._detailStatsSeq.get(entityId) ?? 0) + 1;
|
||||
this._detailStatsSeq.set(entityId, seq);
|
||||
const points = await this._statsService.getDetailStats(entityId, isCounter, this._chartRangeDays);
|
||||
if (this._detailStatsSeq.get(entityId) !== seq) return;
|
||||
const updated = new Map(this._detailStatsData);
|
||||
updated.set(entityId, points);
|
||||
this._detailStatsData = updated;
|
||||
@@ -1185,6 +1206,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
status: task.status,
|
||||
days_until_due: task.days_until_due ?? null,
|
||||
next_due: task.next_due ?? null,
|
||||
next_event_titles: task.next_event_titles ?? [],
|
||||
trigger_active: task.trigger_active,
|
||||
trigger_current_value: task.trigger_current_value ?? null,
|
||||
trigger_current_delta: task.trigger_current_delta ?? null,
|
||||
@@ -1208,8 +1230,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
});
|
||||
}
|
||||
}
|
||||
const statusOrder: Record<string, number> = { overdue: 0, triggered: 1, due_soon: 2, ok: 3 };
|
||||
const byStatus = (a: TaskRow, b: TaskRow) => (statusOrder[a.status] ?? 9) - (statusOrder[b.status] ?? 9);
|
||||
const byStatus = (a: TaskRow, b: TaskRow) => statusRank(a.status) - statusRank(b.status);
|
||||
const byDays = (a: TaskRow, b: TaskRow) => (a.days_until_due ?? 99999) - (b.days_until_due ?? 99999);
|
||||
const byDue = (a: TaskRow, b: TaskRow) => byStatus(a, b) || byDays(a, b);
|
||||
const areaName = (r: TaskRow) =>
|
||||
@@ -1552,7 +1573,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
this._toastUndo = null;
|
||||
this._toastActionLabel = "";
|
||||
this._toastMessage = msg;
|
||||
this._toastTimer = setTimeout(() => { this._toastMessage = ""; this._toastTimer = null; }, 4000);
|
||||
this._toastTimer = setTimeout(() => { this._toastMessage = ""; this._toastTimer = null; }, TOAST_MS);
|
||||
}
|
||||
|
||||
/** A toast with an action button (label defaults to Undo). Used for
|
||||
@@ -1571,7 +1592,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
this._toastUndo = undo;
|
||||
this._toastTimer = setTimeout(() => {
|
||||
this._toastMessage = ""; this._toastUndo = null; this._toastTimer = null;
|
||||
}, 7000);
|
||||
}, ACTION_TOAST_MS);
|
||||
}
|
||||
|
||||
private _runToastUndo(): void {
|
||||
@@ -1747,7 +1768,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
out.push({
|
||||
kind: d.match === "content" ? "content" : "document",
|
||||
entryId: d.entry_id, docId: d.id, docKind: d.kind, url: d.url, page: d.page ?? null,
|
||||
label: d.title || d.filename || d.url || "", sub: d.object_name || "", snippet: d.snippet || "",
|
||||
label: docDisplayName(d), sub: d.object_name || "", snippet: d.snippet || "",
|
||||
score: d.score, icon: d.kind === "weblink" ? "mdi:link-variant" : "mdi:file-document-outline",
|
||||
});
|
||||
}
|
||||
@@ -1886,23 +1907,25 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
// --- Suggested setups (integration signatures, v2.28) ---
|
||||
|
||||
private async _setupBatteryFleet(): Promise<void> {
|
||||
try {
|
||||
const res = await this.hass.connection.sendMessagePromise<{ entry_id: string; task_id?: string }>({
|
||||
type: "maintenance_supporter/battery_fleet/setup",
|
||||
language: this.hass.language || "en",
|
||||
});
|
||||
this._batteryFleetSetupAvailable = false;
|
||||
await this._loadData();
|
||||
// Jump to the fleet task so the user lands on its battery detail section.
|
||||
const obj = this._objects.find((o) => o.entry_id === res.entry_id);
|
||||
const tk = obj?.tasks.find((t2) => t2.id === res.task_id) || obj?.tasks[0];
|
||||
if (obj && tk) {
|
||||
this._showTask(obj.entry_id, tk.id);
|
||||
}
|
||||
this._showToast(t("battery_fleet_setup_done", this._lang));
|
||||
} catch (e) {
|
||||
this._showToast(describeWsError(e, this._lang));
|
||||
const res = await runWs<{ entry_id: string; task_id?: string }>(
|
||||
this,
|
||||
{ type: "maintenance_supporter/battery_fleet/setup", language: this.hass.language || "en" },
|
||||
{
|
||||
reload: async () => {
|
||||
this._batteryFleetSetupAvailable = false;
|
||||
await this._loadData();
|
||||
},
|
||||
onError: (m) => this._showToast(m),
|
||||
},
|
||||
);
|
||||
if (res === undefined) return;
|
||||
// Jump to the fleet task so the user lands on its battery detail section.
|
||||
const obj = this._objects.find((o) => o.entry_id === res?.entry_id);
|
||||
const tk = obj?.tasks.find((t2) => t2.id === res?.task_id) || obj?.tasks[0];
|
||||
if (obj && tk) {
|
||||
this._showTask(obj.entry_id, tk.id);
|
||||
}
|
||||
this._showToast(t("battery_fleet_setup_done", this._lang));
|
||||
}
|
||||
|
||||
private _openSuggestedSetups(): void {
|
||||
@@ -1920,37 +1943,39 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
|
||||
private async _openTemplateGallery(): Promise<void> {
|
||||
this._templateGalleryOpen = true;
|
||||
if (this._templates.length > 0) return;
|
||||
try {
|
||||
const res = await this.hass.connection.sendMessagePromise<{
|
||||
categories: Record<string, { icon?: string }>;
|
||||
templates: Array<{ id: string; name: string; category: string; tasks: unknown[]; disabled?: boolean }>;
|
||||
}>({ type: "maintenance_supporter/templates", language: this._lang });
|
||||
this._templateCategories = res.categories || {};
|
||||
// v2.21: admin-hidden templates stay out of the gallery.
|
||||
this._templates = (res.templates || []).filter((tpl) => !tpl.disabled);
|
||||
} catch {
|
||||
this._showToast(t("action_error", this._lang));
|
||||
}
|
||||
// Refetched on every open (the last list shows meanwhile): the home
|
||||
// profile behind the recommendations can change in Settings. A refusal
|
||||
// toasts the server's reason, like every _runAction path (DRY audit
|
||||
// 2026-09-26).
|
||||
const res = await runWs<{
|
||||
categories: Record<string, { icon?: string }>;
|
||||
templates: Array<{ id: string; name: string; category: string; tasks: unknown[]; disabled?: boolean } & TemplateRecommendation>;
|
||||
profile?: HomeProfile;
|
||||
}>(this, { type: "maintenance_supporter/templates", language: this._lang }, { onError: (m) => this._showToast(m) });
|
||||
if (res === undefined) return;
|
||||
this._templateCategories = res?.categories || {};
|
||||
// v2.21: admin-hidden templates stay out of the gallery.
|
||||
this._templates = (res?.templates || []).filter((tpl) => !tpl.disabled);
|
||||
this._homeProfile = res?.profile ?? null;
|
||||
}
|
||||
|
||||
private async _createFromTemplate(templateId: string): Promise<void> {
|
||||
this._templateBusy = true;
|
||||
try {
|
||||
const res = await this.hass.connection.sendMessagePromise<{ entry_id?: string }>({
|
||||
type: "maintenance_supporter/object/from_template",
|
||||
language: this._lang,
|
||||
template_id: templateId,
|
||||
});
|
||||
this._templateGalleryOpen = false;
|
||||
await this._loadData();
|
||||
this._showToast(t("template_created", this._lang));
|
||||
if (res?.entry_id) this._showObject(res.entry_id);
|
||||
} catch {
|
||||
this._showToast(t("action_error", this._lang));
|
||||
} finally {
|
||||
this._templateBusy = false;
|
||||
}
|
||||
// e.g. "Limit reached" / a create_failed reason instead of "Action failed".
|
||||
const res = await runWs<{ entry_id?: string }>(
|
||||
this,
|
||||
{ type: "maintenance_supporter/object/from_template", language: this._lang, template_id: templateId },
|
||||
{
|
||||
busy: (b) => { this._templateBusy = b; },
|
||||
reload: async () => {
|
||||
this._templateGalleryOpen = false;
|
||||
await this._loadData();
|
||||
},
|
||||
successToast: t("template_created", this._lang),
|
||||
onSuccess: (m) => this._showToast(m),
|
||||
onError: (m) => this._showToast(m),
|
||||
},
|
||||
);
|
||||
if (res?.entry_id) this._showObject(res.entry_id);
|
||||
}
|
||||
|
||||
private _categoryName(catId: string): string {
|
||||
@@ -1959,15 +1984,44 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
return cat[`name_${this._lang}`] || cat["name_en"] || catId;
|
||||
}
|
||||
|
||||
private _renderTemplateCard(tpl: (typeof this._templates)[number], withReasons = false) {
|
||||
const L = this._lang;
|
||||
const country = this._homeProfile?.country ?? null;
|
||||
return html`
|
||||
<button class="template-card ${tpl.dwelling_mismatch ? "not-typical" : ""}" .disabled=${this._templateBusy}
|
||||
title=${tpl.dwelling_mismatch ? t("templates_not_typical", L) : ""}
|
||||
@click=${() => this._createFromTemplate(tpl.id)}>
|
||||
<span class="template-card-name">${tpl.name}</span>
|
||||
<span class="template-card-count">${tpl.tasks.length === 1
|
||||
? t("templates_task_count_one", L)
|
||||
: t("templates_task_count", L).replace("{n}", String(tpl.tasks.length))}</span>
|
||||
${withReasons && tpl.reasons?.length
|
||||
? html`<span class="template-card-reasons">
|
||||
${tpl.reasons.map((r) => html`<span class="template-card-reason">${recommendationReason(r, L, country)}</span>`)}
|
||||
</span>`
|
||||
: nothing}
|
||||
${tpl.set_up
|
||||
? html`<span class="template-card-setup"><ha-icon icon="mdi:check-circle-outline"></ha-icon>${t("templates_set_up", L)}</span>`
|
||||
: nothing}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderTemplateGallery() {
|
||||
if (!this._templateGalleryOpen) return nothing;
|
||||
const L = this._lang;
|
||||
// Group templates by category, preserving category declaration order.
|
||||
// v2.93: templates untypical for the home's dwelling move to the end of
|
||||
// their category (still available — never hidden).
|
||||
const byCat = new Map<string, typeof this._templates>();
|
||||
for (const tpl of this._templates) {
|
||||
if (!byCat.has(tpl.category)) byCat.set(tpl.category, []);
|
||||
byCat.get(tpl.category)!.push(tpl);
|
||||
}
|
||||
for (const tpls of byCat.values()) tpls.sort((a, b) => Number(!!a.dwelling_mismatch) - Number(!!b.dwelling_mismatch));
|
||||
// 2.94: a template the home already uses is not recommended again (the
|
||||
// server says so too; kept here so an older server answer can't list it).
|
||||
const recommended = this._templates.filter((tpl) => tpl.recommended && !tpl.set_up);
|
||||
return html`
|
||||
<div class="palette-backdrop" @click=${() => { this._templateGalleryOpen = false; }}>
|
||||
<div class="template-gallery" @click=${(e: Event) => e.stopPropagation()}>
|
||||
@@ -1977,6 +2031,21 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
@click=${() => { this._templateGalleryOpen = false; }}></ha-icon-button>
|
||||
</div>
|
||||
<div class="template-gallery-body">
|
||||
<div class="template-legal">
|
||||
<ha-icon icon="mdi:scale-balance"></ha-icon>
|
||||
<span>${t("templates_legal_hint", L)}</span>
|
||||
</div>
|
||||
${recommended.length > 0
|
||||
? html`
|
||||
<div class="template-cat recommended">
|
||||
<div class="template-cat-head">
|
||||
<ha-icon icon="mdi:home-heart"></ha-icon>
|
||||
${t("templates_recommended_title", L)}
|
||||
</div>
|
||||
<div class="template-cat-hint">${t("templates_recommended_hint", L)}</div>
|
||||
<div class="template-grid">${recommended.map((tpl) => this._renderTemplateCard(tpl, true))}</div>
|
||||
</div>`
|
||||
: nothing}
|
||||
${this._templates.length === 0
|
||||
? html`<div class="palette-empty">${t("loading", L)}…</div>`
|
||||
: [...byCat.entries()].map(([catId, tpls]) => html`
|
||||
@@ -1985,15 +2054,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
<ha-icon icon="${(this._templateCategories[catId]?.icon as string) || "mdi:folder-outline"}"></ha-icon>
|
||||
${this._categoryName(catId)}
|
||||
</div>
|
||||
<div class="template-grid">
|
||||
${tpls.map((tpl) => html`
|
||||
<button class="template-card" .disabled=${this._templateBusy}
|
||||
@click=${() => this._createFromTemplate(tpl.id)}>
|
||||
<span class="template-card-name">${tpl.name}</span>
|
||||
<span class="template-card-count">${t("templates_task_count", L).replace("{n}", String(tpl.tasks.length))}</span>
|
||||
</button>
|
||||
`)}
|
||||
</div>
|
||||
<div class="template-grid">${tpls.map((tpl) => this._renderTemplateCard(tpl))}</div>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
@@ -2027,30 +2088,47 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
this._bulkSelected = allSelected ? new Set() : new Set(keys);
|
||||
}
|
||||
|
||||
/** Run one WS message per selected row (the endpoints are per-task); reports
|
||||
* how many succeeded and refreshes once at the end. */
|
||||
private async _runBulk(
|
||||
/** THE bulk loop of both selection modes (task rows, objects): one WS call
|
||||
* per item (helpers/ws-run runWsEach — the endpoints are per item), ONE
|
||||
* refresh at the end, and one toast with the successful count plus how
|
||||
* many failed and why. The task and the object loop were identical
|
||||
* copies that swallowed every reason (DRY audit 2026-09-26). The undo
|
||||
* receives the items that went through. */
|
||||
private async _runBulkItems<K>(
|
||||
items: K[],
|
||||
build: (item: K) => Record<string, unknown>,
|
||||
doneMsg: (n: number) => string,
|
||||
endSelection: () => void,
|
||||
undo?: (done: K[]) => void,
|
||||
): Promise<void> {
|
||||
if (items.length === 0) return;
|
||||
this._actionLoading = true;
|
||||
const { done, failed } = await runWsEach(this, items, build);
|
||||
this._actionLoading = false;
|
||||
endSelection();
|
||||
await this._loadData();
|
||||
const msg = bulkResultMessage(doneMsg(done.length), failed, this._lang);
|
||||
if (undo && done.length > 0) this._showUndoToast(msg, () => undo(done));
|
||||
else this._showToast(msg);
|
||||
}
|
||||
|
||||
/** One WS message per selected task row. */
|
||||
private _runBulk(
|
||||
rows: TaskRow[],
|
||||
build: (row: TaskRow) => Record<string, unknown>,
|
||||
doneMsg: (n: number) => string,
|
||||
undo?: () => void,
|
||||
undo?: (done: TaskRow[]) => void,
|
||||
): Promise<void> {
|
||||
const selected = rows.filter((r) => this._bulkSelected.has(this._bulkKey(r)));
|
||||
if (selected.length === 0) return;
|
||||
this._actionLoading = true;
|
||||
let ok = 0;
|
||||
for (const row of selected) {
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise(build(row));
|
||||
ok++;
|
||||
} catch { /* keep going; report the successful count */ }
|
||||
}
|
||||
this._actionLoading = false;
|
||||
this._bulkSelected = new Set();
|
||||
this._bulkMode = false;
|
||||
await this._loadData();
|
||||
if (undo && ok > 0) this._showUndoToast(doneMsg(ok), undo);
|
||||
else this._showToast(doneMsg(ok));
|
||||
return this._runBulkItems(
|
||||
rows.filter((r) => this._bulkSelected.has(this._bulkKey(r))),
|
||||
build,
|
||||
doneMsg,
|
||||
() => {
|
||||
this._bulkSelected = new Set();
|
||||
this._bulkMode = false;
|
||||
},
|
||||
undo,
|
||||
);
|
||||
}
|
||||
|
||||
/** #188: move every selected task to one object (the single-task move's
|
||||
@@ -2098,24 +2176,18 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
}
|
||||
|
||||
/** One WS call per selected object, sequentially (config entries are
|
||||
* removed one at a time on the backend); the successful count is reported. */
|
||||
private async _runObjBulk(type: string, doneMsg: (n: number) => string, undo?: (ids: string[]) => Promise<void>): Promise<void> {
|
||||
const ids = [...this._objBulkSelected];
|
||||
if (ids.length === 0) return;
|
||||
this._actionLoading = true;
|
||||
const done: string[] = [];
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({ type, entry_id: id });
|
||||
done.push(id);
|
||||
} catch { /* keep going; report the successful count */ }
|
||||
}
|
||||
this._actionLoading = false;
|
||||
this._objBulkSelected = new Set();
|
||||
this._objBulkMode = false;
|
||||
await this._loadData();
|
||||
if (undo && done.length > 0) this._showUndoToast(doneMsg(done.length), () => { void undo(done); });
|
||||
else this._showToast(doneMsg(done.length));
|
||||
* removed one at a time on the backend). */
|
||||
private _runObjBulk(type: string, doneMsg: (n: number) => string, undo?: (ids: string[]) => Promise<void>): Promise<void> {
|
||||
return this._runBulkItems(
|
||||
[...this._objBulkSelected],
|
||||
(id) => ({ type, entry_id: id }),
|
||||
doneMsg,
|
||||
() => {
|
||||
this._objBulkSelected = new Set();
|
||||
this._objBulkMode = false;
|
||||
},
|
||||
undo ? (done) => { void undo(done); } : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
private async _objBulkDelete(): Promise<void> {
|
||||
@@ -2178,15 +2250,13 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
}
|
||||
|
||||
private _bulkArchive(rows: TaskRow[]): void {
|
||||
// Capture the selection so the undo can unarchive exactly those.
|
||||
const keys = rows.filter((r) => this._bulkSelected.has(this._bulkKey(r)))
|
||||
.map((r) => ({ entry_id: r.entry_id, task_id: r.task_id }));
|
||||
void this._runBulk(
|
||||
rows,
|
||||
(row) => ({ type: "maintenance_supporter/task/archive", entry_id: row.entry_id, task_id: row.task_id }),
|
||||
(n) => t("bulk_archived", this._lang).replace("{n}", String(n)),
|
||||
async () => {
|
||||
for (const k of keys) {
|
||||
// The undo unarchives exactly the rows that were archived.
|
||||
async (done) => {
|
||||
for (const k of done) {
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/task/unarchive", entry_id: k.entry_id, task_id: k.task_id,
|
||||
@@ -2209,25 +2279,22 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
msg: Record<string, unknown>,
|
||||
opts?: { successToast?: string },
|
||||
): Promise<T | null> {
|
||||
this._actionLoading = true;
|
||||
try {
|
||||
const res = await this.hass.connection.sendMessagePromise<T>(msg);
|
||||
await this._loadData();
|
||||
if (opts?.successToast) this._showToast(opts.successToast);
|
||||
return (res ?? {}) as T;
|
||||
} catch (e) {
|
||||
this._showToast(describeWsError(e, this._lang));
|
||||
return null;
|
||||
} finally {
|
||||
this._actionLoading = false;
|
||||
}
|
||||
// helpers/ws-run runWs bound to the panel's busy flag, reload and toast.
|
||||
const res = await runWs<T>(this, msg, {
|
||||
busy: (b) => { this._actionLoading = b; },
|
||||
reload: () => this._loadData(),
|
||||
successToast: opts?.successToast,
|
||||
onSuccess: (m) => this._showToast(m),
|
||||
onError: (m) => this._showToast(m),
|
||||
});
|
||||
return res === undefined ? null : ((res ?? {}) as T);
|
||||
}
|
||||
|
||||
private async _deleteObject(entryId: string): Promise<void> {
|
||||
const dlg = this.shadowRoot!.querySelector<MaintenanceConfirmDialog>("maintenance-confirm-dialog");
|
||||
const ok = await dlg?.confirm({
|
||||
title: t("delete", this._lang),
|
||||
message: t("confirm_delete_object", this._lang),
|
||||
message: t("delete_object_confirm", this._lang),
|
||||
confirmText: t("delete", this._lang),
|
||||
danger: true,
|
||||
});
|
||||
@@ -2291,7 +2358,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
const dlg = this.shadowRoot!.querySelector<MaintenanceConfirmDialog>("maintenance-confirm-dialog");
|
||||
const ok = await dlg?.confirm({
|
||||
title: t("delete", this._lang),
|
||||
message: t("confirm_delete_task", this._lang),
|
||||
message: t("delete_task_confirm", this._lang),
|
||||
confirmText: t("delete", this._lang),
|
||||
danger: true,
|
||||
});
|
||||
@@ -2557,14 +2624,17 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
private async _handleQuickComplete(
|
||||
entryId: string, taskId: string, task: MaintenanceTask,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/task/quick_complete",
|
||||
entry_id: entryId, task_id: taskId,
|
||||
});
|
||||
this._showToast(t("quick_complete_success", this._lang));
|
||||
} catch (e: unknown) {
|
||||
const code = (e as { code?: string })?.code || "";
|
||||
let failure: { message: string; code: string } | null = null;
|
||||
const res = await runWs(
|
||||
this,
|
||||
{ type: "maintenance_supporter/task/quick_complete", entry_id: entryId, task_id: taskId },
|
||||
{
|
||||
fallbackKey: "action_error",
|
||||
onError: (message, e) => { failure = { message, code: (e as { code?: string })?.code || "" }; },
|
||||
},
|
||||
);
|
||||
if (res === undefined) {
|
||||
const { message, code } = failure ?? { message: "", code: "" };
|
||||
// Both refusals mean "the dialog has to collect something": no
|
||||
// quick-complete defaults configured, or the task demands completion
|
||||
// details a silent complete cannot supply. The dialog opens as the
|
||||
@@ -2579,10 +2649,11 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
} else {
|
||||
// Relay the server's reason (too_early carries the earliest date)
|
||||
// instead of a generic "action failed".
|
||||
this._showToast(describeWsError(e, this._lang, t("action_error", this._lang)));
|
||||
this._showToast(message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
this._showToast(t("quick_complete_success", this._lang));
|
||||
// Silent success: the list must reflect the completion right away — the
|
||||
// subscription delta may lag or be coalesced.
|
||||
try { await this._loadData(); } catch { /* subscription will sync */ }
|
||||
@@ -2595,6 +2666,11 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
const obj = this._getObject(entryId);
|
||||
const task = obj?.tasks.find((tk) => tk.id === taskId);
|
||||
if (!obj || !task) return;
|
||||
// The tab opens NOW, in the click's synchronous part — after the QR /
|
||||
// documents awaits below a window.open is no gesture any more and
|
||||
// popup blockers swallowed the sheet (bug audit 2026-09-26 #2).
|
||||
const tab = preopenTab();
|
||||
let shown = false;
|
||||
this._actionLoading = true;
|
||||
try {
|
||||
const qrBase: Record<string, unknown> = {
|
||||
@@ -2628,7 +2704,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
),
|
||||
};
|
||||
excerpt = {
|
||||
title: manual.title || manual.filename || "Manual",
|
||||
title: docDisplayName(manual) || "Manual",
|
||||
startPage: start, endPage: start + count - 1,
|
||||
// Absolute URL: the sheet lives on a blob: page, where a
|
||||
// relative /api/... href cannot resolve (caught live).
|
||||
@@ -2677,8 +2753,10 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
partsLines,
|
||||
taskRef(obj.object, task),
|
||||
);
|
||||
openHtmlInNewTab(html);
|
||||
openHtmlInNewTab(html, tab);
|
||||
shown = true;
|
||||
} finally {
|
||||
if (!shown) tab?.close();
|
||||
this._actionLoading = false;
|
||||
}
|
||||
}
|
||||
@@ -2696,24 +2774,47 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
/** Ticks not yet confirmed by a reload, per task — and the chain that
|
||||
* sends them one after another. Two quick ticks each built the FULL
|
||||
* state from the not-yet-reloaded task, so the second send wrote the
|
||||
* first item back to unticked (bug audit 2026-09-26). Now every send is
|
||||
* built at send time from the fresh task PLUS the pending ticks, and the
|
||||
* sends are serialized so the server applies them in click order. */
|
||||
private _checklistPending = new Map<string, Record<string, boolean>>();
|
||||
private _checklistChain: Promise<unknown> = Promise.resolve();
|
||||
|
||||
/** #73: persist one checklist tick. Sends the FULL current state (the
|
||||
* server replaces, not merges — idempotent) and reloads so the progress
|
||||
* header and any other open surface agree. */
|
||||
private async _setChecklistItem(entryId: string, taskId: string, item: string, done: boolean): Promise<void> {
|
||||
const obj = this._getObject(entryId);
|
||||
const task = obj?.tasks.find((x) => x.id === taskId);
|
||||
if (!task) return;
|
||||
const state: Record<string, boolean> = {};
|
||||
// #139: a phased task ticks the checklist of the phase currently due.
|
||||
const steps = effectivePhase(task)?.checklist ?? (task.checklist || []);
|
||||
for (const step of steps) {
|
||||
const current = task.checklist_progress?.[step] ?? false;
|
||||
state[step] = step === item ? done : current;
|
||||
}
|
||||
await this._runAction({
|
||||
type: "maintenance_supporter/task/checklist_progress",
|
||||
entry_id: entryId, task_id: taskId, checklist_state: state,
|
||||
private _setChecklistItem(entryId: string, taskId: string, item: string, done: boolean): Promise<void> {
|
||||
const key = `${entryId}/${taskId}`;
|
||||
this._checklistPending.set(key, { ...(this._checklistPending.get(key) ?? {}), [item]: done });
|
||||
const run = this._checklistChain.then(async () => {
|
||||
const task = this._getObject(entryId)?.tasks.find((x) => x.id === taskId);
|
||||
if (!task) return;
|
||||
const pending = this._checklistPending.get(key) ?? {};
|
||||
const state: Record<string, boolean> = {};
|
||||
// #139: a phased task ticks the checklist of the phase currently due.
|
||||
const steps = effectivePhase(task)?.checklist ?? (task.checklist || []);
|
||||
for (const step of steps) {
|
||||
state[step] = step in pending ? pending[step] : (task.checklist_progress?.[step] ?? false);
|
||||
}
|
||||
await this._runAction({
|
||||
type: "maintenance_supporter/task/checklist_progress",
|
||||
entry_id: entryId, task_id: taskId, checklist_state: state,
|
||||
});
|
||||
// _runAction reloaded: the ticks sent so far are in the task now —
|
||||
// drop exactly those, keep any tick made while this send was out.
|
||||
const now = this._checklistPending.get(key);
|
||||
if (now) {
|
||||
for (const [step, value] of Object.entries(state)) {
|
||||
if (step in now && now[step] === value) delete now[step];
|
||||
}
|
||||
if (Object.keys(now).length === 0) this._checklistPending.delete(key);
|
||||
}
|
||||
});
|
||||
this._checklistChain = run.catch(() => undefined);
|
||||
return run;
|
||||
}
|
||||
|
||||
private _openCompleteDialog(
|
||||
@@ -2744,7 +2845,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
objects: this._objects,
|
||||
lang: this._lang,
|
||||
checklist,
|
||||
checklistsEnabled: this._features.checklists,
|
||||
features: this._features,
|
||||
adaptiveEnabled,
|
||||
currencySymbol: this._currencySymbol,
|
||||
viaTagScan: opts?.viaTagScan,
|
||||
@@ -3048,11 +3149,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
* is its own span so narrow/tight rows can drop it (#150: the pill ate a
|
||||
* third of a phone row) while `title`/`aria-label` keep the text reachable. */
|
||||
private _statusBadge(archived: boolean, isDone: boolean, status: string) {
|
||||
const L = this._lang;
|
||||
const cls = archived ? "archived" : (isDone ? "done" : status);
|
||||
const iconKey = archived ? "archived" : (isDone ? "completed" : status);
|
||||
const label = archived ? t("archived", L) : (isDone ? t("completed", L) : t(status, L));
|
||||
return html`<span class="status-badge ${cls}" role="img" title="${label}" aria-label="${label}"><ha-icon icon="${STATUS_ICONS[iconKey] || "mdi:circle-medium"}"></ha-icon><span class="status-label">${label}</span></span>`;
|
||||
return renderStatusBadge({ archived, is_done: isDone, status }, this._lang);
|
||||
}
|
||||
|
||||
private _setOverviewTab(tab: OverviewTab): void {
|
||||
@@ -3111,7 +3208,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
<div class="today-row" @click=${() => this._showTask(row.entry_id, row.task_id)}>
|
||||
<span class="today-dot ${row.trigger_active ? "triggered" : row.status}"></span>
|
||||
<div class="today-main">
|
||||
<div class="today-task">${this._listRef(row.entry_id, row.task_id)}${row.task_name}</div>
|
||||
<div class="today-task">${this._listRef(row.entry_id, row.task_id)}${row.task_name}${renderEventTitles(row.next_event_titles)}</div>
|
||||
<div class="today-object">
|
||||
<span class="today-object-text">${row.object_name} · ${formatDueDays(row.days_until_due, L)}</span>
|
||||
${renderPersonChip(personOf(row), "today-person")}
|
||||
@@ -3188,10 +3285,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
@change=${(e: Event) => { this._filterStatus = (e.target as HTMLSelectElement).value; this._activeViewId = ""; }}
|
||||
>
|
||||
<option value="">${t("all", L)}</option>
|
||||
<option value="overdue">${t("overdue", L)}</option>
|
||||
<option value="due_soon">${t("due_soon", L)}</option>
|
||||
<option value="triggered">${t("triggered", L)}</option>
|
||||
<option value="ok">${t("ok", L)}</option>
|
||||
${STATUS_FILTERS.map((s) => html`<option value=${s} ?selected=${this._filterStatus === s}>${t(s, L)}</option>`)}
|
||||
</select>
|
||||
</label>
|
||||
<label class="filter-field">
|
||||
@@ -3600,7 +3694,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
<div class="object-card-header">
|
||||
<span class="object-card-name">${this._objRef(obj.object)}${obj.object.name}</span>
|
||||
${obj.object.paused
|
||||
? html`<span class="paused-badge" title="${t("object_paused_badge", L)}${obj.object.paused_until ? ` — ${obj.object.paused_until}` : ""}">
|
||||
? html`<span class="paused-badge" title="${t("object_paused_badge", L)}${obj.object.paused_until ? ` — ${formatDate(obj.object.paused_until, L)}` : ""}">
|
||||
<ha-icon icon="mdi:pause-circle-outline"></ha-icon>
|
||||
</span>`
|
||||
: nothing}
|
||||
@@ -3609,7 +3703,9 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
<ha-icon icon="mdi:paperclip"></ha-icon>${obj.object.document_count}
|
||||
</span>`
|
||||
: nothing}
|
||||
<span class="object-card-count">${obj.tasks.length} ${t("tasks_lower", L)}</span>
|
||||
<span class="object-card-count">${obj.tasks.length === 1
|
||||
? t("templates_task_count_one", L)
|
||||
: t("templates_task_count", L).replace("{n}", String(obj.tasks.length))}</span>
|
||||
</div>
|
||||
${obj.object.manufacturer || obj.object.model
|
||||
? html`<div class="object-card-meta">${[obj.object.manufacturer, obj.object.model].filter(Boolean).join(" ")}</div>`
|
||||
@@ -3776,7 +3872,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
: nothing}
|
||||
</td>
|
||||
<td>${row.object_name || "—"}</td>
|
||||
<td>${row.stock !== null ? `${row.stock}${row.unit ? ` ${row.unit}` : ""}` : "—"}</td>
|
||||
<td>${row.stock !== null ? formatQty(row.stock, row.unit, L) : "—"}</td>
|
||||
<td>${row.reorder_threshold ?? "—"}</td>
|
||||
<td>${row.cost != null ? formatCost(row.cost, currency, L) : "—"}</td>
|
||||
<td>${row.storage_location || "—"}</td>
|
||||
@@ -4002,13 +4098,12 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
|
||||
private async _deleteGroup(groupId: string, name: string): Promise<void> {
|
||||
const dlg = this.shadowRoot!.querySelector<MaintenanceConfirmDialog>("maintenance-confirm-dialog");
|
||||
const ok = dlg
|
||||
? await dlg.confirm({
|
||||
title: t("delete_group", this._lang),
|
||||
message: t("delete_group_confirm", this._lang).replace("{name}", name),
|
||||
confirmText: t("delete", this._lang),
|
||||
})
|
||||
: confirm(`${t("delete_group_confirm", this._lang).replace("{name}", name)}`);
|
||||
const ok = await dlg?.confirm({
|
||||
title: t("delete_group", this._lang),
|
||||
message: t("delete_group_confirm", this._lang).replace("{name}", name),
|
||||
confirmText: t("delete", this._lang),
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
await this._runAction({
|
||||
type: "maintenance_supporter/group/delete",
|
||||
@@ -4089,7 +4184,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
</span>
|
||||
<span class="row-head">
|
||||
<span class="cell object-name" @click=${(e: Event) => { e.stopPropagation(); this._showObject(row.entry_id); }}>${row.object_name}</span>
|
||||
<span class="cell task-name" @click=${() => this._showTask(row.entry_id, row.task_id)}>${this._listRef(row.entry_id, row.task_id)}${row.task_name}</span>
|
||||
<span class="cell task-name" @click=${() => this._showTask(row.entry_id, row.task_id)}>${this._listRef(row.entry_id, row.task_id)}${row.task_name}${renderEventTitles(row.next_event_titles)}</span>
|
||||
</span>
|
||||
<span class="task-sub${hasSub ? '' : ' task-sub-empty'}">
|
||||
${row.group_names.length > 0 ? html`
|
||||
@@ -4308,10 +4403,9 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
.then((d) => d?.openCreate(obj.entry_id));
|
||||
}}>${t("add_first_task", L)}</ha-button>
|
||||
</div>`
|
||||
: html`<div class="task-table object-tasks">${[...visibleTasks].sort((a, b) => {
|
||||
const so: Record<string, number> = { overdue: 0, triggered: 1, due_soon: 2, ok: 3 };
|
||||
return (so[a.status] ?? 9) - (so[b.status] ?? 9) || (a.days_until_due ?? 99999) - (b.days_until_due ?? 99999);
|
||||
}).map((task) => html`
|
||||
: html`<div class="task-table object-tasks">${[...visibleTasks].sort((a, b) =>
|
||||
statusRank(a.status) - statusRank(b.status) || (a.days_until_due ?? 99999) - (b.days_until_due ?? 99999),
|
||||
).map((task) => html`
|
||||
<div class="task-row${!task.enabled ? ' task-disabled' : ''}">
|
||||
<span class="cell-badges">
|
||||
${this._statusBadge(!!task.archived, !!task.is_done, task.status)}
|
||||
@@ -4321,7 +4415,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
? html`<span class="doc-badge" title="${task.document_count} ${t("documents", L)}"><ha-icon icon="mdi:paperclip"></ha-icon>${task.document_count}</span>`
|
||||
: nothing}
|
||||
</span>
|
||||
<span class="cell task-name" @click=${() => this._showTask(obj.entry_id, task.id)}>${this._listRef(obj.entry_id, task.id)}${task.name}</span>
|
||||
<span class="cell task-name" @click=${() => this._showTask(obj.entry_id, task.id)}>${this._listRef(obj.entry_id, task.id)}${task.name}${renderEventTitles(task.next_event_titles)}</span>
|
||||
<span class="task-sub${task.responsible_user_id ? '' : ' task-sub-empty'}">${renderUserBadge(task, (id) => this._userService?.getUserName(id) ?? null, (id) => this._userService?.getPerson(id) ?? null)}</span>
|
||||
<span class="cell type">${t(task.type, L)}</span>
|
||||
<span class="due-cell" @click=${() => this._showTask(obj.entry_id, task.id)}>
|
||||
@@ -4626,7 +4720,8 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
currencySymbol: this._currencySymbol,
|
||||
setFilter: (f) => { this._historyFilter = f; },
|
||||
setSearch: (s) => { this._historySearch = s; },
|
||||
openEdit: (entry) => this._openHistoryEdit(entry),
|
||||
// Write tier (task/history/update): no pencil for a read-only member.
|
||||
openEdit: this._isOperator ? undefined : (entry) => this._openHistoryEdit(entry),
|
||||
readingUnit: task?.reading_unit ?? null,
|
||||
taskRef: taskRef(this._selectedEntryId ? this._getObject(this._selectedEntryId)?.object : null, task),
|
||||
// #139: name the phase badge on entries stamped with a phase_id.
|
||||
|
||||
+214
-704
File diff suppressed because it is too large
Load Diff
@@ -498,6 +498,25 @@ export const panelStyles = css`
|
||||
.template-card[disabled] { opacity: .5; pointer-events: none; }
|
||||
.template-card-name { font-weight: 600; font-size: 14px; }
|
||||
.template-card-count { font-size: 12px; color: var(--secondary-text-color); }
|
||||
/* v2.93: recommendations from the home profile. */
|
||||
.template-cat.recommended .template-cat-head { color: var(--primary-color); }
|
||||
.template-cat-hint { font-size: 12px; color: var(--secondary-text-color); margin: -4px 0 8px; }
|
||||
.template-card-reasons { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 2px; }
|
||||
.template-card-reason {
|
||||
font-size: 11px; padding: 1px 6px; border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--primary-color) 12%, transparent); color: var(--primary-text-color);
|
||||
}
|
||||
.template-card.not-typical { opacity: .6; }
|
||||
/* 2.94: the home already has an object for this template. */
|
||||
.template-card-setup {
|
||||
display: inline-flex; align-items: center; gap: 4px; font-size: 12px;
|
||||
color: var(--success-color, #43a047); --mdc-icon-size: 14px;
|
||||
}
|
||||
.template-legal {
|
||||
display: flex; gap: 6px; align-items: flex-start; margin-top: 8px;
|
||||
font-size: 12px; color: var(--secondary-text-color);
|
||||
}
|
||||
.template-legal ha-icon { --mdc-icon-size: 16px; flex: none; margin-top: 1px; }
|
||||
.empty-onboard-hint { color: var(--secondary-text-color); font-size: 13px; margin: 4px 0 12px; }
|
||||
.empty-onboard-actions { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; }
|
||||
|
||||
@@ -755,7 +774,7 @@ export const panelStyles = css`
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.status-chip.warning {
|
||||
.status-chip.due_soon {
|
||||
background: var(--warning-color, #ff9800);
|
||||
color: #000;
|
||||
}
|
||||
@@ -765,6 +784,23 @@ export const panelStyles = css`
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Key set = renderers/status.ts STATUS_KEYS (tripwired): triggered, paused
|
||||
and archived had no rule, so their header chip rendered as bare text. */
|
||||
.status-chip.triggered {
|
||||
background: var(--deep-orange-color, #ff5722);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-chip.paused {
|
||||
background: var(--info-color, #2196f3);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-chip.archived {
|
||||
background: var(--disabled-color, #9e9e9e);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.status-chip.done {
|
||||
background: var(--maint-done-color, #78909c);
|
||||
color: white;
|
||||
@@ -1612,9 +1648,15 @@ export const panelStyles = css`
|
||||
|
||||
:host([narrow]) .tab {
|
||||
/* Tight enough that the four tabs fit 412px in the longest languages —
|
||||
Ukrainian ("Налаштування") overflowed at 12px 16px (overflow sweep). */
|
||||
padding: 12px 8px;
|
||||
Ukrainian ("Налаштування") overflowed at 12px 16px (overflow sweep).
|
||||
Below that (360px phones, 2026-09-27) a label shortens with an
|
||||
ellipsis instead of pushing the bar past the screen edge. */
|
||||
padding: 12px 6px;
|
||||
font-size: 13px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:host([narrow]) .task-header {
|
||||
@@ -1939,7 +1981,7 @@ export const panelStyles = css`
|
||||
.kpi-value { font-size: 14px; }
|
||||
.kpi-value-large { font-size: 18px; }
|
||||
.two-column-layout { grid-template-columns: 1fr; }
|
||||
.tab { padding: 12px 8px; font-size: 13px; }
|
||||
.tab { padding: 12px 6px; font-size: 13px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.task-header { flex-direction: column; align-items: flex-start; }
|
||||
.task-header-actions { width: 100%; justify-content: flex-start; flex-wrap: wrap; }
|
||||
.filter-bar { flex-wrap: wrap; }
|
||||
|
||||
@@ -26,6 +26,24 @@ import { matchesQuery } from "../helpers/search-match";
|
||||
* a different concept: what belongs in the object's record.) */
|
||||
export const EDITABLE_HISTORY_TYPES: readonly HistoryEntryType[] = ["completed", "reset", "skipped"];
|
||||
|
||||
/** Newest first BY TIMESTAMP. The store appends, so array order is entry
|
||||
* order — a backdated completion (#133) lands last although it happened
|
||||
* before the ones above it, and every "reverse the array" view showed it
|
||||
* as the newest (bug audit 2026-09-26). Stable: entries with the same (or
|
||||
* an unparsable) timestamp keep "later appended = newer". */
|
||||
export function newestFirst<T extends { timestamp?: string | null }>(history: readonly T[] | null | undefined): T[] {
|
||||
const ms = (e: T): number => {
|
||||
const raw = e.timestamp ?? "";
|
||||
// Date-only strings are local days (like formatDate) — not UTC midnight.
|
||||
const v = Date.parse(raw.length === 10 ? `${raw}T00:00:00` : raw);
|
||||
return Number.isNaN(v) ? -Infinity : v;
|
||||
};
|
||||
return (history ?? [])
|
||||
.map((entry, index) => ({ entry, index, at: ms(entry) }))
|
||||
.sort((a, b) => (b.at - a.at) || (b.index - a.index))
|
||||
.map((x) => x.entry);
|
||||
}
|
||||
|
||||
/** What one history row needs — the panel's HistoryContext is a superset;
|
||||
* the dialog and the overview card build just this. */
|
||||
export interface HistoryEntryContext {
|
||||
@@ -34,7 +52,10 @@ export interface HistoryEntryContext {
|
||||
hass: HomeAssistant;
|
||||
/** Currency symbol for cost display (defaults to €). */
|
||||
currencySymbol: string;
|
||||
openEdit: (entry: HistoryEntry) => void;
|
||||
/** Opens the history-edit dialog. Absent = the viewer may not edit
|
||||
* (task/history/update is WRITE tier): no pencil at all instead of one
|
||||
* whose Save the server refuses (bug audit 2026-09-26 #2). */
|
||||
openEdit?: (entry: HistoryEntry) => void;
|
||||
/** v2.20 (#83): unit + delta-vs-previous for reading-task entries. */
|
||||
readingUnit?: string | null;
|
||||
readingDelta?: (entry: HistoryEntry) => number | null;
|
||||
@@ -119,7 +140,7 @@ export function renderHistoryList(task: MaintenanceTask, ctx: HistoryContext) {
|
||||
|
||||
return html`
|
||||
<div class="history-timeline">
|
||||
${[...filtered].reverse().map((entry: HistoryEntry) => renderHistoryEntry(entry, ctx))}
|
||||
${newestFirst(filtered).map((entry: HistoryEntry) => renderHistoryEntry(entry, ctx))}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -168,7 +189,8 @@ function renderHistoryReadings(entry: HistoryEntry, ctx: HistoryEntryContext) {
|
||||
export function renderHistoryEntry(entry: HistoryEntry, ctx: HistoryEntryContext, opts: HistoryEntryOptions = {}) {
|
||||
const L = ctx.lang;
|
||||
const { compact = false, showRef = true, showBadges = true, showEdit = true } = opts;
|
||||
const editable = showEdit && EDITABLE_HISTORY_TYPES.includes(entry.type);
|
||||
const openEdit = ctx.openEdit;
|
||||
const editable = showEdit && !!openEdit && EDITABLE_HISTORY_TYPES.includes(entry.type);
|
||||
return html`
|
||||
<div class="history-entry${compact ? " compact" : ""}">
|
||||
${compact
|
||||
@@ -185,7 +207,7 @@ export function renderHistoryEntry(entry: HistoryEntry, ctx: HistoryEntryContext
|
||||
${editable
|
||||
? html`<button class="history-edit-btn"
|
||||
title=${t("history_edit_button", L)}
|
||||
@click=${() => ctx.openEdit(entry)}>
|
||||
@click=${() => openEdit!(entry)}>
|
||||
<ha-icon icon="mdi:pencil"></ha-icon>
|
||||
</button>`
|
||||
: nothing}
|
||||
|
||||
@@ -23,8 +23,10 @@ import { renderRecommendationBars } from "./recommendation";
|
||||
import { renderSeasonalCardCompact, renderSeasonalCardExpanded } from "./seasonal";
|
||||
import { renderCostDurationCard } from "./charts";
|
||||
import { renderDaysProgress } from "./progress";
|
||||
import { renderHistoryEntry, renderHistoryFilters, renderHistoryList, type HistoryContext } from "./history";
|
||||
import { newestFirst, renderHistoryEntry, renderHistoryFilters, renderHistoryList, type HistoryContext } from "./history";
|
||||
import { renderStatusBadge } from "./status";
|
||||
import { clampPhaseCursor, effectivePhase, hasPhases } from "../helpers/phases";
|
||||
import { renderEventTitles } from "../helpers/event-titles";
|
||||
import "../components/task-documents";
|
||||
|
||||
export interface TaskDetailContext {
|
||||
@@ -118,19 +120,14 @@ function renderTaskHeader(task: MaintenanceTask, ctx: TaskDetailContext) {
|
||||
const L = ctx.lang;
|
||||
const isOperator = ctx.isOperator;
|
||||
|
||||
// Determine status chip — use the backend-computed status. A completed
|
||||
// one-time task is shown as archived ("done") rather than its raw "ok".
|
||||
const statusClass = task.archived ? "archived" : (task.is_done ? "done" : (task.status === "due_soon" ? "warning" : (task.status || "ok")));
|
||||
const statusText = task.archived ? t("archived", L) : (task.is_done ? t("completed", L) : t(task.status || "ok", L));
|
||||
|
||||
return html`
|
||||
<div class="task-header">
|
||||
<div class="task-header-title">
|
||||
<span class="task-name-breadcrumb" @click=${() => ctx.showTaskView()}>${task.name}</span>
|
||||
<span class="task-name-breadcrumb" @click=${() => ctx.showTaskView()}>${task.name}${renderEventTitles(task.next_event_titles)}</span>
|
||||
${renderRefChip(ctx.taskRef ?? null, t("ref_number", L))}
|
||||
<span class="breadcrumb-separator">·</span>
|
||||
<span class="object-name-breadcrumb" @click=${() => ctx.showObject()}>${ctx.objectName}</span>
|
||||
<span class="status-chip ${statusClass}">${statusText}</span>
|
||||
${renderStatusBadge(task, L, "chip")}
|
||||
${task.due_override ? html`<span class="postponed-badge" title="${t("postponed_to", L)}">
|
||||
<ha-icon icon="mdi:calendar-arrow-right"></ha-icon>${formatDate(task.due_override, L)}
|
||||
</span>` : nothing}
|
||||
@@ -167,9 +164,14 @@ function renderTaskHeader(task: MaintenanceTask, ctx: TaskDetailContext) {
|
||||
${!isOperator ? html`
|
||||
<div class="popup-menu-item" @click=${() => ctx.duplicateTask()}>${t("duplicate", L)}</div>
|
||||
<div class="popup-menu-item" @click=${() => ctx.moveTask()}>${t("move_task", L)}</div>
|
||||
<div class="popup-menu-item" @click=${() => { ctx.closeMoreMenu(); ctx.promptReset(); }}>${t("reset", L)}</div>
|
||||
<div class="popup-menu-item" @click=${() => { ctx.closeMoreMenu(); ctx.promptPostpone(); }}>${t("postpone", L)}…</div>
|
||||
<div class="popup-menu-item" @click=${() => { ctx.closeMoreMenu(); ctx.snoozeTask(); }}>${t("snooze", L)}</div>
|
||||
` : nothing}
|
||||
<!-- Reset / Postpone / Snooze are household actions (read tier,
|
||||
helpers/permissions HOUSEHOLD_ACTIONS): offered to everyone,
|
||||
like Complete / Skip and the Lovelace quick-actions Reset. -->
|
||||
<div class="popup-menu-item" @click=${() => { ctx.closeMoreMenu(); ctx.promptReset(); }}>${t("reset", L)}</div>
|
||||
<div class="popup-menu-item" @click=${() => { ctx.closeMoreMenu(); ctx.promptPostpone(); }}>${t("postpone", L)}…</div>
|
||||
<div class="popup-menu-item" @click=${() => { ctx.closeMoreMenu(); ctx.snoozeTask(); }}>${t("snooze", L)}</div>
|
||||
${!isOperator ? html`
|
||||
<div class="popup-menu-item" @click=${() => { ctx.closeMoreMenu(); ctx.toggleArchive(!!task.archived); }}>${task.archived ? t("unarchive", L) : t("archive", L)}</div>
|
||||
<div class="popup-menu-divider"></div>
|
||||
<div class="popup-menu-item danger" @click=${() => { ctx.closeMoreMenu(); ctx.deleteTask(); }}>${t("delete", L)}</div>
|
||||
@@ -401,10 +403,12 @@ function renderRecommendationCard(task: MaintenanceTask, ctx: TaskDetailContext)
|
||||
task.interval_confidence || "medium", L,
|
||||
)}
|
||||
<div class="recommendation-actions">
|
||||
<ha-button appearance="filled"
|
||||
@click=${() => ctx.applySuggestion(suggested)}>
|
||||
${t("apply_suggestion", L)}
|
||||
</ha-button>
|
||||
${ctx.isOperator
|
||||
? nothing /* task/apply_suggestion is write tier — the server refuses a read-only member */
|
||||
: html`<ha-button appearance="filled" class="apply-suggestion"
|
||||
@click=${() => ctx.applySuggestion(suggested)}>
|
||||
${t("apply_suggestion", L)}
|
||||
</ha-button>`}
|
||||
<ha-button appearance="plain"
|
||||
@click=${() => ctx.reanalyze()}>
|
||||
${t("reanalyze", L)}
|
||||
@@ -420,7 +424,9 @@ function renderRecommendationCard(task: MaintenanceTask, ctx: TaskDetailContext)
|
||||
|
||||
function renderRecentActivities(task: MaintenanceTask, ctx: TaskDetailContext) {
|
||||
const L = ctx.lang;
|
||||
const recent = task.history.slice(-3).reverse();
|
||||
// By timestamp, not array order: a backdated completion (#133) is appended
|
||||
// last but belongs further down (bug audit 2026-09-26).
|
||||
const recent = newestFirst(task.history).slice(0, 3);
|
||||
|
||||
if (recent.length === 0) {
|
||||
return nothing;
|
||||
|
||||
@@ -20,6 +20,34 @@ export const STATUS_COLORS: Record<string, string> = {
|
||||
paused: "var(--info-color, #2196f3)",
|
||||
};
|
||||
|
||||
/** The live task statuses by urgency, most urgent first — the order every
|
||||
* list sorts by and every status picker offers (the card editor's filter,
|
||||
* the panel's status filter, the calendar pills). Four hand-kept copies of
|
||||
* the rank map and the list had drifted: the panel's filter offered
|
||||
* overdue → due soon → triggered while every sort ranked triggered above
|
||||
* due soon (DRY audit 2026-09-26). archived / paused are deliberately not
|
||||
* here — they are states of the object, not a place in the queue. */
|
||||
export const STATUS_ORDER = ["overdue", "triggered", "due_soon", "ok"] as const;
|
||||
|
||||
/** The statuses that want attention — the card's default filter and the
|
||||
* strategy's "actionable" view. Python twin: helpers/todo_mirror
|
||||
* MIRRORED_STATUSES / intent._ACTIONABLE (tests/test_frontend_const_parity.py). */
|
||||
export const ACTIONABLE_STATUSES = ["overdue", "triggered", "due_soon"] as const;
|
||||
|
||||
const _STATUS_RANK: Record<string, number> = Object.fromEntries(STATUS_ORDER.map((s, i) => [s, i]));
|
||||
|
||||
/** Sort rank of a status (lower = more urgent); anything outside
|
||||
* STATUS_ORDER sorts after "ok". */
|
||||
export function statusRank(status: string | null | undefined): number {
|
||||
return _STATUS_RANK[status ?? ""] ?? STATUS_ORDER.length;
|
||||
}
|
||||
|
||||
export type ActionableStatus = (typeof ACTIONABLE_STATUSES)[number];
|
||||
|
||||
export function isActionableStatus(status: string | null | undefined): status is ActionableStatus {
|
||||
return (ACTIONABLE_STATUSES as readonly string[]).includes(status ?? "");
|
||||
}
|
||||
|
||||
export const STATUS_ICONS: Record<string, string> = {
|
||||
ok: "mdi:check-circle",
|
||||
due_soon: "mdi:alert-circle",
|
||||
|
||||
@@ -300,6 +300,15 @@ export function formatCost(amount: number, symbol?: string, lang?: string, digit
|
||||
return symbol ? `${num} ${symbol}` : num;
|
||||
}
|
||||
|
||||
/** A quantity with its optional unit ("6 pcs", "1,5 l" under a decimal-comma
|
||||
* profile) — spare-part stock and quantities everywhere. Four surfaces had
|
||||
* interpolated the raw number (`${stock} ${unit}`), bypassing the profile
|
||||
* number format (DRY audit 2026-09-26; tripwired in dry-round4.test.ts). */
|
||||
export function formatQty(n: number, unit?: string | null, lang?: string): string {
|
||||
const num = formatNumber(n, lang);
|
||||
return unit ? `${num} ${unit}` : num;
|
||||
}
|
||||
|
||||
/** BCP-47 locale for the "language" default: the UI language regionalized
|
||||
* by the server country when Intl knows the combination (#140). Explicit
|
||||
* profile formats (DMY/MDY/YMD/system) never reach this path. */
|
||||
@@ -547,6 +556,8 @@ export const refStyles = css`
|
||||
font-variant-numeric: tabular-nums; letter-spacing: .02em; vertical-align: middle; white-space: nowrap;
|
||||
user-select: all;
|
||||
}
|
||||
/* #189: "· Residual waste, Paper" after a calendar-driven task's name. */
|
||||
.event-titles { color: var(--secondary-text-color); font-weight: 400; }
|
||||
`;
|
||||
|
||||
export const sharedStyles = css`
|
||||
|
||||
@@ -292,6 +292,8 @@ export interface MaintenanceTask {
|
||||
archived_reason?: string | null;
|
||||
days_until_due?: number | null;
|
||||
next_due?: string | null;
|
||||
/** #189: titles of the calendar events behind next_due (calendar kind). */
|
||||
next_event_titles?: string[];
|
||||
/** Per-occurrence postpone: the ISO date the current cycle was deferred to. */
|
||||
due_override?: string | null;
|
||||
trigger_active: boolean;
|
||||
@@ -591,6 +593,8 @@ export interface TaskRow {
|
||||
area_id: string | null;
|
||||
responsible_user_id: string | null;
|
||||
group_names: string[];
|
||||
/** #189: titles of the calendar events behind next_due (calendar kind). */
|
||||
next_event_titles: string[];
|
||||
}
|
||||
|
||||
// HomeAssistant type (minimal for our needs)
|
||||
@@ -653,8 +657,10 @@ export interface HomeAssistant {
|
||||
* `config._panel_custom` (the panel card imports ours from there, #174). */
|
||||
panels?: Record<string, { url_path?: string; title?: string | null; config?: { _panel_custom?: { name?: string; module_url?: string } } | null }>;
|
||||
/** Current access token — used for authenticated `fetch()` to our HTTP
|
||||
* document views (a WebSocket frame can't carry a multipart file upload). */
|
||||
auth?: { data?: { access_token?: string } };
|
||||
* document views (a WebSocket frame can't carry a multipart file upload).
|
||||
* It expires after 30 minutes; `helpers/photo-upload.ts authFetch`
|
||||
* renews it first. */
|
||||
auth?: { data?: { access_token?: string }; expired?: boolean; refreshAccessToken?: () => Promise<void> };
|
||||
}
|
||||
|
||||
export interface HAUser {
|
||||
|
||||
Reference in New Issue
Block a user