updated apps

This commit is contained in:
2026-07-14 23:57:03 -04:00
parent 6cc7212cef
commit 010e828e9c
797 changed files with 45153 additions and 4246 deletions
@@ -0,0 +1,64 @@
/**
* <maintenance-adopt-problem-sensors-dialog>: the suggested-spare-part link.
*
* Pins: a candidate carrying suggested_part_id/name renders the part chip, and
* the adopt payload forwards part_id for exactly those candidates.
*/
import { expect, fixture, html } from "@open-wc/testing";
import "../components/adopt-problem-sensors-dialog.js";
import type { MaintenanceAdoptProblemSensorsDialog } from "../components/adopt-problem-sensors-dialog";
import { type SentMessage, createMockHass } from "./_test-utils.js";
const SENSORS = [
{
entity_id: "binary_sensor.toner_low", name: "Toner low", state: "on",
device_id: "d1", device_name: "Printer", area_name: "Office",
suggested_entry_id: "e1", suggested_object_name: "Printer",
suggested_part_id: "part_toner", suggested_part_name: "Toner cartridge",
},
{
entity_id: "binary_sensor.pump_problem", name: "Pump problem", state: "off",
device_id: "d2", device_name: "Pump", area_name: null,
suggested_entry_id: null, suggested_object_name: "Pump",
suggested_part_id: null, suggested_part_name: null,
},
];
async function mountOpen(): Promise<{ el: MaintenanceAdoptProblemSensorsDialog; sent: SentMessage[] }> {
const { hass, sent } = createMockHass({
handlers: {
"maintenance_supporter/problem_sensors/discover": () => ({ sensors: SENSORS }),
"maintenance_supporter/problem_sensors/adopt": () => ({ tasks_created: 2, objects_created: 1, total: 2 }),
},
});
const el = await fixture<MaintenanceAdoptProblemSensorsDialog>(html`
<maintenance-adopt-problem-sensors-dialog .hass=${hass}></maintenance-adopt-problem-sensors-dialog>
`);
await el.open();
await el.updateComplete;
return { el, sent };
}
describe("adopt-problem-sensors dialog: suggested part", () => {
it("renders the part chip only for candidates with a suggested part", async () => {
const { el } = await mountOpen();
const rows = el.shadowRoot!.querySelectorAll(".row");
expect(rows.length).to.equal(2);
expect(rows[0].querySelector(".row-part")?.textContent).to.include("Toner cartridge");
expect(rows[1].querySelector(".row-part")).to.equal(null);
});
it("forwards part_id in the adopt payload for the matched candidate only", async () => {
const { el, sent } = await mountOpen();
el.shadowRoot!.querySelectorAll<HTMLElement>("ha-button")[1].click(); // Adopt selected
await el.updateComplete;
await new Promise((r) => setTimeout(r, 0));
const adopt = sent.find((m) => m.type === "maintenance_supporter/problem_sensors/adopt")! as {
selections: Array<{ entity_id: string; part_id?: string }>;
};
const byId = Object.fromEntries(adopt.selections.map((s) => [s.entity_id, s]));
expect(byId["binary_sensor.toner_low"].part_id).to.equal("part_toner");
expect(byId["binary_sensor.pump_problem"].part_id).to.equal(undefined);
});
});
@@ -0,0 +1,108 @@
/**
* Component test for the Lovelace card's saved-view scope (v2.26 — the last
* open piece of roadmap item 2).
*
* Pins:
* - `view_id` in the card config applies the view's status/user/label
* filters ON TOP of the card's own filters (AND semantics, unlike the
* panel where applying a view replaces the filter state)
* - the `current_user` sentinel resolves against hass.user client-side
* - a deleted/unknown view id degrades to "no view filter", never an
* inexplicably empty card (same fallback as backend notification routing)
*/
import { expect, fixture, html, waitUntil } from "@open-wc/testing";
import "../maintenance-card.js";
import type { MaintenanceSupporterCard } from "../maintenance-card";
const T = (
id: string, name: string, status: string,
extra: Record<string, unknown> = {},
) => ({ id, name, status, days_until_due: 1, type: "service", ...extra });
const O = (entry_id: string, name: string, tasks: unknown[]) => ({
entry_id, object: { id: entry_id, name }, tasks,
});
function mockObjects() {
return [
O("e1", "Garden shed", [
T("t1", "Sharpen mower blades", "overdue", { labels: ["garden"], responsible_user_id: "u1" }),
T("t2", "Oil hedge trimmer", "due_soon", { labels: ["garden"], responsible_user_id: "u2" }),
]),
O("e2", "Kitchen", [
T("t3", "Descale kettle", "overdue", { labels: ["kitchen"], responsible_user_id: "u1" }),
T("t4", "Clean extractor", "ok", { labels: [], responsible_user_id: null }),
]),
];
}
const VIEWS = [
{ id: "vgarden", name: "Garden", filters: { status: "", user_id: null, label: "garden", archived: false, sort_mode: "due_date", group_by: "none" } },
{ id: "vmine", name: "Mine", filters: { status: "", user_id: "current_user", label: null, archived: false, sort_mode: "due_date", group_by: "none" } },
{ id: "voverdue", name: "Overdue only", filters: { status: "overdue", user_id: null, label: null, archived: false, sort_mode: "due_date", group_by: "none" } },
];
function mockHass() {
return {
language: "en",
user: { id: "u1", name: "Tester", is_admin: true, is_owner: true },
connection: {
sendMessagePromise: async (msg: { type: string }) => {
if (msg.type === "maintenance_supporter/objects") return { objects: mockObjects() };
if (msg.type === "maintenance_supporter/views/list") return { views: VIEWS };
return { overdue: 0, due_soon: 0, triggered: 0, ok: 0, total: 0 };
},
subscribeMessage: async () => () => {},
},
};
}
async function mount(config: Record<string, unknown> = {}): Promise<MaintenanceSupporterCard> {
const el = await fixture<MaintenanceSupporterCard>(
html`<maintenance-supporter-card .hass=${mockHass() as never}></maintenance-supporter-card>`
);
el.setConfig({ type: "custom:maintenance-supporter-card", show_actions: false, ...config } as never);
await waitUntil(
() => el.shadowRoot!.querySelectorAll(".task-name, .empty-card").length > 0,
"card renders",
{ timeout: 2000 }
);
await el.updateComplete;
return el;
}
const names = (el: MaintenanceSupporterCard) =>
[...el.shadowRoot!.querySelectorAll(".task-name")].map((n) => n.textContent?.trim() || "");
describe("maintenance-card saved-view scope", () => {
it("applies the view's label filter", async () => {
const el = await mount({ view_id: "vgarden" });
await waitUntil(() => names(el).length === 2, "view filter applied", { timeout: 2000 });
expect(names(el)).to.deep.equal(["Sharpen mower blades", "Oil hedge trimmer"]);
});
it("resolves the current_user sentinel against hass.user", async () => {
const el = await mount({ view_id: "vmine" });
await waitUntil(() => names(el).length === 2, "user filter applied", { timeout: 2000 });
// u1 owns t1 + t3; t2 is u2's, t4 unassigned.
expect(names(el)).to.deep.equal(["Sharpen mower blades", "Descale kettle"]);
});
it("ANDs the view with the card's own filters", async () => {
const el = await mount({ view_id: "vgarden", filter_status: ["overdue"] });
await waitUntil(() => names(el).length === 1, "combined filter applied", { timeout: 2000 });
expect(names(el)).to.deep.equal(["Sharpen mower blades"]);
});
it("applies the view's own status filter", async () => {
const el = await mount({ view_id: "voverdue" });
await waitUntil(() => names(el).length === 2, "status filter applied", { timeout: 2000 });
expect(names(el)).to.deep.equal(["Sharpen mower blades", "Descale kettle"]);
});
it("a deleted view id degrades to no view filter, not an empty card", async () => {
const el = await mount({ view_id: "deleted_view" });
await waitUntil(() => names(el).length === 4, "fallback shows all", { timeout: 2000 });
expect(names(el)).to.have.length(4);
});
});
@@ -0,0 +1,92 @@
/**
* Spare-parts section: rows render the stock badge / identifiers / storage
* location, the low state is flagged, and the add form only appears for
* writers.
*/
import { expect, fixture, html } from "@open-wc/testing";
import "../components/parts-section";
import type { MaintenancePartsSection } from "../components/parts-section";
import type { MaintenancePart } from "../types";
const PARTS: MaintenancePart[] = [
{
id: "p1",
name: "HEPA-Filter",
mpn: "00754869",
vendor: "Bosch",
storage_location: "Keller Regal B",
stock: 1,
reorder_threshold: 1,
is_low: true,
unit: "pcs",
shopping_url: "https://example.com/buy",
},
{ id: "p2", name: "Brush", stock: null, is_low: false },
];
async function mount(canWrite: boolean): Promise<MaintenancePartsSection> {
const el = await fixture<MaintenancePartsSection>(html`
<maintenance-parts-section
.hass=${{ language: "en", connection: { sendMessagePromise: async () => ({}) } } as never}
.entryId=${"e1"}
.parts=${PARTS}
.canWrite=${canWrite}
></maintenance-parts-section>
`);
await el.updateComplete;
return el;
}
describe("parts-section", () => {
it("renders a row per part with stock badge, identifiers and location", async () => {
const el = await mount(false);
const rows = el.shadowRoot!.querySelectorAll(".part-row");
expect(rows.length).to.equal(2);
const first = rows[0] as HTMLElement;
expect(first.classList.contains("low")).to.be.true;
expect(first.querySelector(".stock-badge")!.textContent).to.include("1");
expect(first.querySelector(".part-meta")!.textContent).to.include("MPN: 00754869");
expect(first.querySelector(".part-meta")!.textContent).to.include("Keller Regal B");
// Catalog-only part (untracked stock) shows no badge.
expect((rows[1] as HTMLElement).querySelector(".stock-badge")).to.equal(null);
// Shopping link resolves on the name.
const link = first.querySelector(".part-name a") as HTMLAnchorElement;
expect(link.href).to.equal("https://example.com/buy");
});
it("gates editing on canWrite", async () => {
const reader = await mount(false);
// Read-only users keep the (read-action) documents paperclip — one per
// part — but no edit/delete/restock buttons.
const readerBtns = [...reader.shadowRoot!.querySelectorAll("ha-icon-button")];
expect(readerBtns.length).to.equal(reader.shadowRoot!.querySelectorAll(".part-row").length);
expect(readerBtns.every((b) => b.querySelector('ha-icon[icon="mdi:paperclip"]'))).to.be.true;
const writer = await mount(true);
expect(writer.shadowRoot!.querySelectorAll("ha-icon-button").length).to.be.greaterThan(0);
// Add button opens the inline form with native inputs (dialog-input trap).
(writer.shadowRoot!.querySelector(".section-head ha-button") as HTMLElement).click();
await writer.updateComplete;
expect(writer.shadowRoot!.querySelector(".part-form")).to.not.equal(null);
expect(writer.shadowRoot!.querySelectorAll(".part-form input").length).to.be.greaterThan(5);
});
it("does not render a non-http(s) shopping_url as a link (XSS guard)", async () => {
const el = await fixture<MaintenancePartsSection>(html`
<maintenance-parts-section
.hass=${{ language: "en", connection: { sendMessagePromise: async () => ({}) } } as never}
.entryId=${"e1"}
.parts=${[
{ id: "p1", name: "Evil", stock: null, is_low: false, shopping_url: "javascript:alert(1)" },
{ id: "p2", name: "Good", stock: null, is_low: false, shopping_url: "https://ok.example/x" },
] as MaintenancePart[]}
.canWrite=${false}
></maintenance-parts-section>
`);
await el.updateComplete;
const rows = el.shadowRoot!.querySelectorAll(".part-row");
// Row 0 (javascript:) → plain text, NO anchor. Row 1 (https) → anchor.
expect(rows[0].querySelector(".part-name a"), "javascript: url must not become a link").to.equal(null);
expect(rows[0].querySelector(".part-name")!.textContent!.trim()).to.equal("Evil");
expect(rows[1].querySelector(".part-name a"), "https url stays a link").to.not.equal(null);
});
});
@@ -0,0 +1,109 @@
/**
* Component tests for the saved-views dialog (v2.24).
*
* Pins:
* - Save sends the CURRENT filters under the typed name and emits the
* server's returned list via `saved-views-changed`.
* - Delete sends the view id and emits the trimmed list.
* - The name input is a native <input> (the <ha-textfield> panel-context trap).
*/
import { expect, fixture, html } from "@open-wc/testing";
import "../components/saved-views-dialog.js";
import type { MaintenanceSavedViewsDialog } from "../components/saved-views-dialog";
import type { SavedView, SavedViewFilters } from "../types";
function makeHass(sent: unknown[]) {
return {
language: "en",
connection: {
sendMessagePromise: async (msg: Record<string, unknown>) => {
sent.push(msg);
if (msg.type === "maintenance_supporter/views/save") {
return { views: [{ id: "new1", name: msg.name, filters: msg.filters }] };
}
return { views: [] }; // delete -> empty
},
},
};
}
const FILTERS: SavedViewFilters = {
status: "overdue",
user_id: "current_user",
archived: true,
sort_mode: "area",
group_by: "user",
};
const EXISTING: SavedView[] = [
{ id: "v1", name: "Kitchen overdue", filters: { ...FILTERS } },
];
async function mount(sent: unknown[]) {
const el = await fixture<MaintenanceSavedViewsDialog>(html`
<maintenance-saved-views-dialog .hass=${makeHass(sent)}></maintenance-saved-views-dialog>
`);
await el.open(FILTERS, EXISTING);
await el.updateComplete;
return el;
}
describe("saved-views dialog", () => {
it("lists existing views and uses a native input for the name", async () => {
const el = await mount([]);
const names = [...el.shadowRoot!.querySelectorAll(".row-name")].map((n) => n.textContent?.trim());
expect(names).to.deep.equal(["Kitchen overdue"]);
const input = el.shadowRoot!.querySelector(".name-input");
expect(input?.tagName).to.equal("INPUT");
});
it("saves the current filters under the typed name and emits the new list", async () => {
const sent: unknown[] = [];
const el = await mount(sent);
let emitted: SavedView[] | null = null;
el.addEventListener("saved-views-changed", (e) => {
emitted = (e as CustomEvent<{ views: SavedView[] }>).detail.views;
});
const input = el.shadowRoot!.querySelector<HTMLInputElement>(".name-input")!;
input.value = "My view";
input.dispatchEvent(new Event("input"));
await el.updateComplete;
const saveBtn = [...el.shadowRoot!.querySelectorAll("ha-button")].find((b) =>
(b.textContent || "").toLowerCase().includes("save"),
)!;
(saveBtn as HTMLElement).click();
await el.updateComplete;
await new Promise((r) => setTimeout(r, 0));
const saveMsg = (sent as Record<string, unknown>[]).find(
(m) => m.type === "maintenance_supporter/views/save",
)!;
expect(saveMsg.name).to.equal("My view");
expect(saveMsg.filters).to.deep.equal(FILTERS);
expect(emitted, "emits server list").to.not.be.null;
expect(emitted![0].name).to.equal("My view");
});
it("deletes a view by id and emits the trimmed list", async () => {
const sent: unknown[] = [];
const el = await mount(sent);
let emitted: SavedView[] | null = null;
el.addEventListener("saved-views-changed", (e) => {
emitted = (e as CustomEvent<{ views: SavedView[] }>).detail.views;
});
const delBtn = el.shadowRoot!.querySelector<HTMLElement>(".row ha-icon-button")!;
delBtn.click();
await el.updateComplete;
await new Promise((r) => setTimeout(r, 0));
const delMsg = (sent as Record<string, unknown>[]).find(
(m) => m.type === "maintenance_supporter/views/delete",
)!;
expect(delMsg.view_id).to.equal("v1");
expect(emitted).to.deep.equal([]);
});
});
@@ -0,0 +1,84 @@
/**
* Contrast tripwire for status badges (dark-mode & a11y QA, v2.24).
*
* The roadmap claimed status colours were "routed through theme tokens with a
* tripwire blocking bare colours" — but no such test existed, and the badges
* used white text on light backgrounds (green/orange/grey) at 2.22.8:1, below
* the 3:1 WCAG floor for UI components. This renders each badge with the REAL
* sharedStyles (fallback hex applies with no HA theme loaded) and asserts the
* computed text-on-background contrast clears 3:1 — so reverting a light badge
* to white text fails the build.
*/
import { expect, fixture, html } from "@open-wc/testing";
import { LitElement, css } from "lit";
import { customElement } from "lit/decorators.js";
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"];
@customElement("badge-contrast-probe")
class BadgeContrastProbe extends LitElement {
static styles = [sharedStyles, css`:host { display: block; }`];
render() {
return html`${STATUSES.map(
(s) => html`<span class="status-badge ${s}" data-s=${s}>${s}</span>`,
)}`;
}
}
@customElement("chip-contrast-probe")
class ChipContrastProbe extends LitElement {
static styles = [panelStyles, css`:host { display: block; }`];
render() {
return html`${CHIP_STATUSES.map(
(s) => html`<span class="status-chip ${s}" data-s=${s}>${s}</span>`,
)}`;
}
}
function parseRgb(v: string): [number, number, number] {
const m = v.match(/rgba?\(([^)]+)\)/);
if (!m) throw new Error("not rgb: " + v);
const [r, g, b] = m[1].split(",").map((x) => parseFloat(x));
return [r, g, b];
}
function relLum([r, g, b]: [number, number, number]): number {
const lin = (c: number) => {
const s = c / 255;
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
};
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
}
function contrast(fg: string, bg: string): number {
const a = relLum(parseRgb(fg));
const b = relLum(parseRgb(bg));
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
}
describe("status badge contrast (WCAG UI 3:1)", () => {
it("every status badge clears 3:1 text-on-background", async () => {
const el = await fixture<BadgeContrastProbe>(html`<badge-contrast-probe></badge-contrast-probe>`);
await el.updateComplete;
for (const s of STATUSES) {
const badge = el.shadowRoot!.querySelector<HTMLElement>(`.status-badge[data-s="${s}"]`)!;
const cs = getComputedStyle(badge);
const ratio = contrast(cs.color, cs.backgroundColor);
expect(ratio, `${s}: ${cs.color} on ${cs.backgroundColor} = ${ratio.toFixed(2)}:1`).to.be.greaterThan(3.0);
}
});
it("every task-detail status chip clears 3:1 text-on-background", async () => {
const el = await fixture<ChipContrastProbe>(html`<chip-contrast-probe></chip-contrast-probe>`);
await el.updateComplete;
for (const s of CHIP_STATUSES) {
const chip = el.shadowRoot!.querySelector<HTMLElement>(`.status-chip[data-s="${s}"]`)!;
const cs = getComputedStyle(chip);
const ratio = contrast(cs.color, cs.backgroundColor);
expect(ratio, `chip ${s}: ${cs.color} on ${cs.backgroundColor} = ${ratio.toFixed(2)}:1`).to.be.greaterThan(3.0);
}
});
});
@@ -0,0 +1,101 @@
/**
* <maintenance-task-dialog>: the live "what happens next" trigger hint.
*
* The sensor-based trigger form reads the bound entity's CURRENT state and
* spells out the semantics (a delta counter counts from the current reading,
* not from zero, and restarts after each completion). Pins: the hint renders
* with the live value + computed due point, updates per trigger type, and
* stays absent when no entity/state is available.
*/
import { expect, fixture, html } from "@open-wc/testing";
import "../components/task-dialog.js";
import type { MaintenanceTaskDialog } from "../components/task-dialog";
import { createMockHass } from "./_test-utils.js";
async function mountCreate(states: Record<string, unknown>): Promise<MaintenanceTaskDialog> {
const { hass } = createMockHass({ states });
const el = await fixture<MaintenanceTaskDialog>(html`
<maintenance-task-dialog .hass=${hass}></maintenance-task-dialog>
`);
el.openCreate("e1", []);
await el.updateComplete;
(el as any)._scheduleType = "sensor_based";
await el.updateComplete;
return el;
}
const hint = (el: MaintenanceTaskDialog): string | null =>
el.shadowRoot!.querySelector(".trigger-live-hint")?.textContent?.trim() ?? null;
describe("task-dialog live trigger hint", () => {
it("delta counter: shows current reading, computed due point, and restart semantics", async () => {
const el = await mountCreate({
"sensor.pump_hours": { state: "660", attributes: { unit_of_measurement: "h" } },
});
(el as any)._triggerEntityId = "sensor.pump_hours";
(el as any)._triggerEntityIds = ["sensor.pump_hours"];
(el as any)._triggerType = "counter";
(el as any)._triggerDeltaMode = true;
(el as any)._triggerTargetValue = "100";
await el.updateComplete;
const text = hint(el)!;
expect(text, "hint rendered").to.not.equal(null);
expect(text).to.include("660 h"); // current reading
expect(text).to.include("760 h"); // computed due point (660 + 100)
});
it("threshold: shows current value and the above-target", async () => {
const el = await mountCreate({
"sensor.pressure": { state: "1.2", attributes: { unit_of_measurement: "bar" } },
});
(el as any)._triggerEntityId = "sensor.pressure";
(el as any)._triggerEntityIds = ["sensor.pressure"];
(el as any)._triggerType = "threshold";
(el as any)._triggerAbove = "1.5";
await el.updateComplete;
const text = hint(el)!;
expect(text).to.include("1.2 bar");
expect(text).to.include("1.5 bar");
});
it("renders nothing without a bound entity or without targets", async () => {
const el = await mountCreate({
"sensor.pressure": { state: "1.2", attributes: {} },
});
(el as any)._triggerType = "threshold";
(el as any)._triggerAbove = "1.5"; // target set, but NO entity
await el.updateComplete;
expect(hint(el)).to.equal(null);
(el as any)._triggerEntityId = "sensor.pressure";
(el as any)._triggerEntityIds = ["sensor.pressure"];
(el as any)._triggerAbove = ""; // entity set, but no target
await el.updateComplete;
expect(hint(el)).to.equal(null);
});
it("editing a delta task uses the since-last-completion wording (baseline is not the current reading)", async () => {
const { hass } = createMockHass({
states: { "sensor.pump_hours": { state: "660", attributes: { unit_of_measurement: "h" } } },
});
const el = await fixture<MaintenanceTaskDialog>(html`
<maintenance-task-dialog .hass=${hass}></maintenance-task-dialog>
`);
await el.openEdit("e1", {
id: "t1", name: "Service", type: "custom",
schedule_type: "sensor_based", warning_days: 7, enabled: true,
trigger_config: {
type: "counter", entity_id: "sensor.pump_hours", entity_ids: ["sensor.pump_hours"],
trigger_target_value: 100, trigger_delta_mode: true,
},
} as any);
await el.updateComplete;
const text = hint(el)!;
expect(text, "hint rendered in edit mode").to.not.equal(null);
// Must NOT claim the count starts at the current reading (the stored
// baseline is the last completion, not "now") — no computed 760 h.
expect(text).to.not.include("760");
expect(text).to.include("100 h");
});
});
@@ -123,3 +123,48 @@ describe("task-documents", () => {
}
});
});
// ── part mode (v2.26): same component, linking via part_ids ─────────────────
const P_LINKED = { id: "d1", kind: "file", title: "Datasheet", filename: "d.pdf", mime: "application/pdf", size: 100, tags: [], task_ids: [], part_ids: ["p1"] };
const P_AVAIL = { id: "d2", kind: "weblink", title: "Spec page", url: "https://x/spec", tags: [], task_ids: [], part_ids: [] };
async function mountPart(docs: unknown[] = [P_LINKED, P_AVAIL]) {
const { hass, sent } = createMockHass({
handlers: {
"maintenance_supporter/documents/list": () => ({ documents: docs }),
"maintenance_supporter/documents/update": () => ({ id: "d1" }),
},
});
const el = await fixture<MaintenanceTaskDocuments>(html`
<maintenance-task-documents .hass=${hass} .entryId=${"e1"} .partId=${"p1"} .canWrite=${true}></maintenance-task-documents>
`);
await new Promise((r) => setTimeout(r, 30));
await el.updateComplete;
return { el, sent };
}
describe("task-documents in part mode", () => {
it("filters by part_ids and links via part_ids (never task_ids)", async () => {
const { el, sent } = await mountPart();
const rows = el.shadowRoot!.querySelectorAll(".tdoc-row");
expect(rows.length).to.equal(1);
expect(rows[0].querySelector(".tdoc-title")!.textContent).to.contain("Datasheet");
const select = el.shadowRoot!.querySelector<HTMLSelectElement>(".tdoc-select")!;
select.value = "d2";
select.dispatchEvent(new Event("change"));
await el.updateComplete;
el.shadowRoot!.querySelector<HTMLButtonElement>(".tdoc-btn")!.click();
await el.updateComplete;
const msg = sent.find((m) => m.type === "maintenance_supporter/documents/update" && m.doc_id === "d2");
expect(msg, "link WS sent").to.exist;
expect(msg!.part_ids).to.deep.equal(["p1"]);
expect(msg!.task_ids, "task_ids untouched in part mode").to.be.undefined;
});
it("offers no per-task page input for a linked PDF in part mode", async () => {
const { el } = await mountPart([P_LINKED]);
expect(el.shadowRoot!.querySelector(".tdoc-page")).to.not.exist;
});
});
@@ -189,10 +189,14 @@ export const calendarStyles = css`
letter-spacing: 0.4px;
color: #fff;
}
.cal-status-overdue { background: #d32f2f; }
.cal-status-triggered { background: #038fc7; }
.cal-status-due_soon { background: #f9a825; color: #000; }
.cal-status-ok { background: #2e7d32; }
/* Same tokens as .status-badge (status-constants.ts) — the calendar used
to keep its own palette (triggered was even BLUE here) so identical
statuses wore different colors per view, and none followed the theme. */
.cal-status-overdue { background: var(--error-color, #f44336); }
.cal-status-triggered { background: var(--deep-orange-color, #ff5722); }
.cal-status-due_soon { background: var(--warning-color, #ff9800); color: #000; }
/* Dark text — white on green is only 2.8:1 (below the 3:1 UI floor). */
.cal-status-ok { background: var(--success-color, #4caf50); color: #000; }
@media (max-width: 600px) {
.cal-controls { padding: 10px 12px; }
@@ -0,0 +1,358 @@
/** Dialog to discover HA "problem" sensors and adopt them as maintenance tasks.
*
* Lists binary/problem sensors that aren't already tracked, preselects them all,
* 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.
*/
import { css, html, LitElement, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { t, ensureLocale } from "../styles";
import { describeWsError } from "../ws-errors";
import type { HomeAssistant } from "../types";
interface ProblemSensor {
entity_id: string;
name: string;
state: string;
device_id: string | null;
device_name: string | null;
area_name: string | null;
suggested_entry_id: string | null;
suggested_object_name: string;
suggested_part_id: string | null;
suggested_part_name: string | null;
}
interface DiscoverResponse {
sensors: ProblemSensor[];
}
interface AdoptResponse {
tasks_created: number;
objects_created: number;
total: number;
errors?: string[];
}
export class MaintenanceAdoptProblemSensorsDialog extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _open = false;
@state() private _loading = false;
@state() private _adopting = false;
@state() private _error = "";
@state() private _sensors: ProblemSensor[] = [];
@state() private _selected: Set<string> = new Set();
private _localeReady = false;
private get _lang(): string {
return this.hass?.language || "en";
}
updated(changed: Map<string, unknown>): void {
if (changed.has("hass") && this.hass && !this._localeReady) {
this._localeReady = true;
ensureLocale(this._lang).then(() => this.requestUpdate());
}
}
public async open(): Promise<void> {
this._open = true;
this._loading = true;
this._error = "";
this._sensors = [];
this._selected = new Set();
try {
const resp = await this.hass.connection.sendMessagePromise<DiscoverResponse>({
type: "maintenance_supporter/problem_sensors/discover",
});
this._sensors = resp.sensors || [];
this._selected = new Set(this._sensors.map((s) => s.entity_id));
} catch (e) {
this._error = describeWsError(e, this._lang);
} finally {
this._loading = false;
}
}
private _close(): void {
this._open = false;
}
private _toggle = (entityId: string): void => {
const next = new Set(this._selected);
if (next.has(entityId)) next.delete(entityId);
else next.add(entityId);
this._selected = next;
};
private _toggleAll = (): void => {
if (this._selected.size === this._sensors.length) {
this._selected = new Set();
} else {
this._selected = new Set(this._sensors.map((s) => s.entity_id));
}
};
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: s.suggested_entry_id ?? undefined,
object_name: s.suggested_object_name,
device_id: s.device_id ?? undefined,
part_id: s.suggested_part_id ?? 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;
}
};
render() {
if (!this._open) return html``;
const L = this._lang;
const allSelected =
this._sensors.length > 0 && this._selected.size === this._sensors.length;
return html`
<div class="overlay" @click=${this._close}>
<div class="card" @click=${(e: Event) => e.stopPropagation()}>
<div class="title">${t("adopt_problem_title", L)}</div>
<div class="hint">${t("adopt_problem_hint", L)}</div>
${this._error ? html`<div class="error">${this._error}</div>` : nothing}
${this._loading
? html`<div class="loading">…</div>`
: this._sensors.length === 0
? html`<div class="empty">${t("adopt_problem_none", L)}</div>`
: html`
<label class="select-all">
<input
type="checkbox"
.checked=${allSelected}
@change=${this._toggleAll}
/>
<span>${t("selected", L)}: ${this._selected.size} / ${this._sensors.length}</span>
</label>
<div class="list">
${this._sensors.map((s) => {
const checked = this._selected.has(s.entity_id);
const active = s.state === "on";
const sub = [s.device_name, s.area_name]
.filter(Boolean)
.join(" · ");
return html`
<label class="row">
<input
type="checkbox"
.checked=${checked}
@change=${() => this._toggle(s.entity_id)}
/>
<div class="row-main">
<div class="row-top">
<span class="row-name">${s.name}</span>
<span class="chip ${active ? "chip-active" : "chip-ok"}">
${active
? t("adopt_problem_active", L)
: t("adopt_problem_ok", L)}
</span>
</div>
${sub ? html`<div class="row-sub">${sub}</div>` : nothing}
<div class="row-target">
${s.suggested_object_name}${s.suggested_entry_id
? nothing
: html` <span class="new-tag">${t("adopt_problem_new_object", L)}</span>`}
</div>
${s.suggested_part_name
? html`<div class="row-part">
<ha-icon icon="mdi:package-variant-closed"></ha-icon>
${t("adopt_problem_part", L).replace("{name}", s.suggested_part_name)}
</div>`
: nothing}
</div>
</label>
`;
})}
</div>
`}
<div class="actions">
<ha-button appearance="plain" @click=${this._close}>
${t("cancel", L)}
</ha-button>
<ha-button
@click=${this._adopt}
.disabled=${this._selected.size === 0 || this._adopting}
>
${t("adopt_problem_adopt", L)}
</ha-button>
</div>
</div>
</div>
`;
}
static styles = css`
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.card {
background: var(--card-background-color, #fff);
color: var(--primary-text-color);
border-radius: 12px;
padding: 20px;
display: flex;
flex-direction: column;
gap: 12px;
min-width: 360px;
max-width: 560px;
width: 90vw;
max-height: 80vh;
overflow: hidden;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
}
.title {
font-size: 18px;
font-weight: 500;
}
.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;
}
.select-all {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--secondary-text-color);
cursor: pointer;
}
.select-all input {
cursor: pointer;
}
.list {
display: flex;
flex-direction: column;
gap: 6px;
overflow-y: auto;
max-height: 50vh;
}
.row {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 8px;
border: 1px solid var(--divider-color);
border-radius: 6px;
cursor: pointer;
}
.row input {
margin-top: 2px;
cursor: pointer;
}
.row-main {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
flex: 1;
}
.row-top {
display: flex;
align-items: center;
gap: 8px;
}
.row-name {
font-weight: 500;
font-size: 13px;
}
.row-sub {
color: var(--secondary-text-color);
font-size: 12px;
}
.row-target {
color: var(--secondary-text-color);
font-size: 12px;
}
.row-part {
color: var(--secondary-text-color);
font-size: 12px;
display: flex;
align-items: center;
gap: 4px;
}
.row-part ha-icon {
--mdc-icon-size: 14px;
}
.new-tag {
font-style: italic;
}
.chip {
font-size: 11px;
padding: 1px 8px;
border-radius: 10px;
white-space: nowrap;
}
.chip-active {
background: var(--error-color, #f44336);
color: #fff;
}
.chip-ok {
background: var(--divider-color);
color: var(--secondary-text-color);
}
.actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 8px;
}
`;
}
if (!customElements.get("maintenance-adopt-problem-sensors-dialog")) {
customElements.define(
"maintenance-adopt-problem-sensors-dialog",
MaintenanceAdoptProblemSensorsDialog,
);
}
@@ -3,7 +3,7 @@
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import type { HomeAssistant } from "../types";
import { t } from "../styles";
import { t, nativeFieldStyles } from "../styles";
import { describeWsError } from "../ws-errors";
export class MaintenanceCompleteDialog extends LitElement {
@@ -17,6 +17,10 @@ export class MaintenanceCompleteDialog extends LitElement {
// v2.20 (#83): task type + unit drive the reading-value field below.
@property() public taskType = "";
@property() public readingUnit = "";
/** Buy task (part_ref): default restock quantity — shows an editable qty field. */
@property({ attribute: false }) public restockDefault: number | null = null;
/** "Consumes: 1× HEPA-Filter (Shelf B)" hint lines for consuming tasks. */
@property({ type: Array }) public consumesInfo: string[] = [];
@state() private _open = false;
@state() private _notes = "";
@state() private _cost = "";
@@ -29,6 +33,7 @@ export class MaintenanceCompleteDialog extends LitElement {
@state() private _photoPreview = "";
@state() private _photoUploading = false;
@state() private _readingValue = "";
@state() private _restockQty = "";
public open(): void {
if (this._open) return;
@@ -43,6 +48,7 @@ export class MaintenanceCompleteDialog extends LitElement {
this._photoPreview = "";
this._photoUploading = false;
this._readingValue = "";
this._restockQty = this.restockDefault !== null ? String(this.restockDefault) : "";
}
private _toggleCheck(idx: number): void {
@@ -129,6 +135,10 @@ export class MaintenanceCompleteDialog extends LitElement {
const rv = parseFloat(this._readingValue);
if (!isNaN(rv)) data.reading_value = rv;
}
if (this.restockDefault !== null && this._restockQty !== "") {
const rq = parseInt(this._restockQty, 10);
if (!isNaN(rq) && rq >= 1) data.restock_quantity = rq;
}
await this.hass.connection.sendMessagePromise(data);
this._open = false;
this.dispatchEvent(new CustomEvent("task-completed"));
@@ -171,6 +181,20 @@ export class MaintenanceCompleteDialog extends LitElement {
@input=${(e: Event) => (this._readingValue = (e.target as HTMLInputElement).value)} />
</label>`
: nothing}
${this.consumesInfo.length
? html`<div class="consumes-hint">
${this.consumesInfo.map((line) => html`<div>${line}</div>`)}
</div>`
: nothing}
${this.restockDefault !== null
? html`
<label class="field">
<span class="field-label">${t("restock_quantity_label", L)}</span>
<input type="number" step="1" min="1" class="field-input"
.value=${this._restockQty}
@input=${(e: Event) => (this._restockQty = (e.target as HTMLInputElement).value)} />
</label>`
: nothing}
<!-- Native <input>s rather than <ha-textfield>: when this dialog
is opened from a Lovelace card via dialog-mount, ha-textfield
isn't yet registered (HA loads it lazily when its own panels
@@ -248,7 +272,7 @@ export class MaintenanceCompleteDialog extends LitElement {
`;
}
static styles = css`
static styles = [nativeFieldStyles, css`
.dialog-title {
font-size: 18px;
font-weight: 500;
@@ -266,27 +290,18 @@ export class MaintenanceCompleteDialog extends LitElement {
gap: 8px;
padding-top: 16px;
}
.consumes-hint {
font-size: 13px;
color: var(--secondary-text-color);
border-left: 3px solid var(--primary-color);
padding: 4px 8px;
margin: 4px 0 8px;
}
.error {
color: var(--error-color, #f44336);
font-size: 13px;
}
.field { display: flex; flex-direction: column; gap: 4px; }
.field-label {
font-size: 12px;
color: var(--secondary-text-color);
}
.field-input {
padding: 8px 10px; font-size: 14px;
background: var(--secondary-background-color, rgba(0,0,0,0.06));
color: var(--primary-text-color);
border: 1px solid var(--divider-color); border-radius: 6px;
font-family: inherit;
width: 100%; box-sizing: border-box;
}
.field-input:focus {
outline: none;
border-color: var(--primary-color);
}
/* .field/.field-label/.field-input come from nativeFieldStyles */
.photo-pick {
display: inline-flex;
align-items: center;
@@ -387,7 +402,7 @@ export class MaintenanceCompleteDialog extends LitElement {
color: var(--text-primary-color, #fff);
border-color: var(--primary-color);
}
`;
`];
}
// Safe registration — avoids duplicate define when both panel and card load
@@ -3,7 +3,7 @@
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import type { HomeAssistant } from "../types";
import { t } from "../styles";
import { t, nativeFieldStyles } from "../styles";
export interface ConfirmOptions {
title: string;
@@ -126,22 +126,15 @@ export class MaintenanceConfirmDialog extends LitElement {
`;
}
static styles = css`
static styles = [nativeFieldStyles, css`
.dialog-title {
font-size: 18px;
font-weight: 500;
padding-bottom: 12px;
}
.field { display: flex; flex-direction: column; gap: 4px; margin-top: 12px; }
.field-label { font-size: 12px; color: var(--secondary-text-color); }
.field-input {
padding: 8px 10px; font-size: 14px;
background: var(--secondary-background-color, rgba(0,0,0,0.06));
color: var(--primary-text-color);
border: 1px solid var(--divider-color); border-radius: 6px;
font-family: inherit; width: 100%; box-sizing: border-box;
}
.field-input:focus { outline: none; border-color: var(--primary-color); }
/* shared native-field scaffold from nativeFieldStyles; the prompt input
follows the message text, hence the extra top margin here */
.field { margin-top: 12px; }
.content {
padding: 8px 0;
min-width: 280px;
@@ -162,7 +155,7 @@ export class MaintenanceConfirmDialog extends LitElement {
ha-button.danger {
--mdc-theme-primary: var(--error-color, #f44336);
}
`;
`];
}
if (!customElements.get("maintenance-confirm-dialog")) {
@@ -8,6 +8,7 @@
*/
import { LitElement, html, css, nothing } from "lit";
import { isSafeHttpUrl } from "../helpers/url";
import { property, state } from "lit/decorators.js";
import { t, ensureLocale } from "../styles";
import { describeWsError } from "../ws-errors";
@@ -246,7 +247,7 @@ export class MaintenanceDocumentsSection extends LitElement {
if (doc.kind === "file") void this._preview(doc);
// Only open http(s) links — never a javascript:/data: URL (the same scheme
// guard the rest of the panel applies before opening user-supplied URLs).
else if (doc.url && /^https?:\/\//i.test(doc.url)) window.open(doc.url, "_blank", "noopener");
else if (isSafeHttpUrl(doc.url)) window.open(doc.url, "_blank", "noopener");
}
private _startEdit(doc: MaintenanceDocument): void {
@@ -484,7 +485,7 @@ export class MaintenanceDocumentsSection extends LitElement {
</button>`
: html`<a
class="icon-btn"
href=${doc.url && /^https?:\/\//i.test(doc.url) ? doc.url : "#"}
href=${isSafeHttpUrl(doc.url) ? doc.url : "#"}
target="_blank"
rel="noopener noreferrer"
title=${t("doc_open", L)}
@@ -9,6 +9,7 @@
*/
import { LitElement, html, css, nothing } from "lit";
import { isSafeHttpUrl } from "../helpers/url";
import { property, state } from "lit/decorators.js";
import { t, STATUS_COLORS } from "../styles";
import { describeWsError } from "../ws-errors";
@@ -239,7 +240,7 @@ export class MaintenanceObjectQuickActionsDialog extends LitElement {
<span class="meta-value">${
// Only render http(s) values as links (never javascript:/data:);
// value-based so it works in every UI language, not just English.
/^https?:\/\//i.test(value)
isSafeHttpUrl(value)
? html`<a href="${value}" target="_blank" rel="noopener noreferrer">${value}</a>`
: value
}</span>
@@ -0,0 +1,483 @@
/**
* Spare parts & consumables section on the object detail page.
*
* Shows the object's parts list (stock badge, storage location, identifiers,
* shopping link), lets admins add/edit/delete parts, and adjust stock
* (inventory correction / manual restock). Follows the documents-section
* conventions: standalone LitElement fed by the panel, native <input>s in the
* inline form (the ha-textfield-in-custom-panel trap), t() i18n.
*
* After a create/delete (entity set changes → backend reloads the entry) it
* fires "parts-changed" so the panel re-fetches objects; stock adjustments
* update the local copy from the WS response for instant feedback.
*/
import { LitElement, html, css, nothing } from "lit";
import { isSafeHttpUrl } from "../helpers/url";
import { property, state } from "lit/decorators.js";
import { t, ensureLocale } from "../styles";
import { describeWsError } from "../ws-errors";
import type { HomeAssistant, MaintenancePart } from "../types";
// Per-part document links (v2.26) — the task-documents component in part mode.
import "./task-documents";
interface PartForm {
id?: string;
name: string;
vendor: string;
mpn: string;
gtin: string;
storage_location: string;
product_url: string;
unit: string;
cost: string;
stock: string;
reorder_threshold: string;
restock_quantity: string;
auto_buy_task: boolean;
notes: string;
}
const EMPTY_FORM: PartForm = {
name: "",
vendor: "",
mpn: "",
gtin: "",
storage_location: "",
product_url: "",
unit: "",
cost: "",
stock: "",
reorder_threshold: "",
restock_quantity: "",
auto_buy_task: true,
notes: "",
};
export class MaintenancePartsSection extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public entryId!: string;
@property({ attribute: false }) public parts: MaintenancePart[] = [];
@property({ type: Boolean }) public canWrite = false;
@state() private _editing: PartForm | null = null;
@state() private _busy = false;
@state() private _error = "";
@state() private _restockFor: string | null = null;
@state() private _restockQty = "";
@state() private _restockInvalid = false;
@state() private _docsFor: string | null = null;
private get _lang(): string {
return this.hass?.locale?.language || this.hass?.language || "en";
}
public connectedCallback(): void {
super.connectedCallback();
// Lazy-load the locale bundle for t() (same pattern as documents-section).
void ensureLocale(this._lang).then(() => this.requestUpdate());
}
private _notifyChanged(): void {
this.dispatchEvent(new CustomEvent("parts-changed", { bubbles: true, composed: true }));
}
private async _send<T>(msg: Record<string, unknown>): Promise<T | null> {
this._busy = true;
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;
}
}
private _openAdd(): void {
this._editing = { ...EMPTY_FORM };
}
private _openEdit(part: MaintenancePart): void {
this._editing = {
id: part.id,
name: part.name,
vendor: part.vendor || "",
mpn: part.mpn || "",
gtin: part.gtin || "",
storage_location: part.storage_location || "",
product_url: part.product_url || "",
unit: part.unit || "",
cost: part.cost != null ? String(part.cost) : "",
stock: part.stock != null ? String(part.stock) : "",
reorder_threshold: part.reorder_threshold != null ? String(part.reorder_threshold) : "",
restock_quantity: part.restock_quantity != null ? String(part.restock_quantity) : "",
auto_buy_task: !!part.auto_buy_task,
notes: part.notes || "",
};
}
private _formValue(f: PartForm): Record<string, unknown> {
const num = (s: string): number | null => (s.trim() === "" ? null : Number(s));
return {
entry_id: this.entryId,
name: f.name.trim(),
vendor: f.vendor.trim() || null,
mpn: f.mpn.trim() || null,
gtin: f.gtin.trim() || null,
storage_location: f.storage_location.trim() || null,
product_url: f.product_url.trim() || null,
unit: f.unit.trim() || null,
cost: num(f.cost),
stock: num(f.stock),
reorder_threshold: num(f.reorder_threshold),
restock_quantity: num(f.restock_quantity),
auto_buy_task: f.auto_buy_task,
notes: f.notes.trim() || null,
};
}
private async _save(): Promise<void> {
const f = this._editing;
if (!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) {
this._editing = null;
this._notifyChanged();
}
}
private async _delete(part: MaintenancePart): Promise<void> {
const result = await this._send<{ success: boolean }>({
type: "maintenance_supporter/part/delete",
entry_id: this.entryId,
part_id: part.id,
});
if (result !== null) this._notifyChanged();
}
private async _restock(part: MaintenancePart): Promise<void> {
const qty = parseInt(this._restockQty, 10);
if (!Number.isFinite(qty) || qty === 0) {
// Don't silently swallow a no-op amount — keep the input open and mark
// it so the user sees WHY nothing happened (0 / empty / not a number).
this._restockInvalid = true;
return;
}
this._restockInvalid = false;
const result = await this._send<{ stock: number }>({
type: "maintenance_supporter/part/restock",
entry_id: this.entryId,
part_id: part.id,
delta: qty,
});
this._restockFor = null;
if (result !== null) {
// Instant local feedback; the panel refresh follows via parts-changed.
part.stock = result.stock;
this.requestUpdate();
this._notifyChanged();
}
}
private _identLine(part: MaintenancePart): string {
return [part.vendor, part.mpn ? `MPN: ${part.mpn}` : "", part.gtin ? `GTIN: ${part.gtin}` : ""]
.filter(Boolean)
.join(" · ");
}
private _renderRow(part: MaintenancePart) {
const L = this._lang;
const tracked = part.stock !== null && part.stock !== undefined;
const ident = this._identLine(part);
const docsOpen = this._docsFor === part.id;
return html`
<div class="part-row ${part.is_low ? "low" : ""}">
<ha-icon class="part-icon" icon=${part.is_low ? "mdi:cart-arrow-down" : "mdi:package-variant-closed"}></ha-icon>
<div class="part-main">
<div class="part-name">
${isSafeHttpUrl(part.shopping_url)
? html`<a href=${part.shopping_url} target="_blank" rel="noopener noreferrer">${part.name}</a>`
: 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>`
: nothing}</span
>`
: nothing}
</div>
<div class="part-meta">
${ident ? html`<span>${ident}</span>` : nothing}
${part.storage_location
? html`<span class="loc"><ha-icon icon="mdi:map-marker-outline"></ha-icon>${part.storage_location}</span>`
: nothing}
</div>
</div>
<ha-icon-button
title=${t("documents", L)}
class=${docsOpen ? "docs-open" : ""}
@click=${() => (this._docsFor = docsOpen ? null : part.id)}
><ha-icon icon="mdi:paperclip"></ha-icon
></ha-icon-button>
${this.canWrite
? html`
${this._restockFor === part.id
? html`
<input
class="restock-input${this._restockInvalid ? " invalid" : ""}"
type="number"
.value=${this._restockQty}
placeholder="+1"
@input=${(e: Event) => (this._restockQty = (e.target as HTMLInputElement).value)}
@keydown=${(e: KeyboardEvent) => {
if (e.key === "Enter") this._restock(part);
if (e.key === "Escape") this._restockFor = null;
}}
/>
<ha-icon-button title=${t("save", L)} @click=${() => this._restock(part)}
><ha-icon icon="mdi:check"></ha-icon
></ha-icon-button>
`
: html`
<ha-icon-button
title=${t("part_restock", L)}
.disabled=${this._busy}
@click=${() => {
this._restockFor = part.id;
this._restockInvalid = false;
this._restockQty = String(part.restock_quantity || 1);
}}
><ha-icon icon="mdi:plus-minus-variant"></ha-icon
></ha-icon-button>
`}
<ha-icon-button title=${t("edit", L)} .disabled=${this._busy} @click=${() => this._openEdit(part)}
><ha-icon icon="mdi:pencil"></ha-icon
></ha-icon-button>
<ha-icon-button title=${t("delete", L)} .disabled=${this._busy} @click=${() => this._delete(part)}
><ha-icon icon="mdi:delete-outline"></ha-icon
></ha-icon-button>
`
: nothing}
</div>
${docsOpen
? html`<div class="part-docs">
<maintenance-task-documents
.hass=${this.hass}
.entryId=${this.entryId}
.partId=${part.id}
.canWrite=${this.canWrite}
></maintenance-task-documents>
</div>`
: nothing}
`;
}
private _field(label: string, key: keyof PartForm, opts: { type?: string; placeholder?: string } = {}) {
const f = this._editing!;
return html`
<label class="form-field">
<span>${label}</span>
<input
type=${opts.type || "text"}
.value=${String(f[key] ?? "")}
placeholder=${opts.placeholder || ""}
@input=${(e: Event) => {
(this._editing as unknown as Record<string, unknown>)[key] = (e.target as HTMLInputElement).value;
this.requestUpdate();
}}
/>
</label>
`;
}
private _renderForm() {
const L = this._lang;
const f = this._editing!;
return html`
<div class="part-form">
<div class="form-grid">
${this._field(t("part_name", L), "name")}
${this._field(t("part_vendor", L), "vendor")}
${this._field("MPN", "mpn")}
${this._field("GTIN / EAN", "gtin", { placeholder: "4006381333931" })}
${this._field(t("part_storage_location", L), "storage_location")}
${this._field(t("part_product_url", L), "product_url", { placeholder: "https://…" })}
${this._field(t("part_unit", L), "unit")}
${this._field(t("part_cost", L), "cost", { type: "number" })}
${this._field(t("part_stock", L), "stock", { type: "number" })}
${this._field(t("part_reorder_threshold", L), "reorder_threshold", { type: "number" })}
${this._field(t("part_restock_quantity", L), "restock_quantity", { type: "number" })}
<label class="form-field checkbox">
<input
type="checkbox"
.checked=${f.auto_buy_task}
@change=${(e: Event) => {
this._editing = { ...f, auto_buy_task: (e.target as HTMLInputElement).checked };
}}
/>
<span>${t("part_auto_buy", L)}</span>
</label>
</div>
<div class="form-actions">
<ha-button appearance="plain" @click=${() => (this._editing = null)}>${t("cancel", L)}</ha-button>
<ha-button .disabled=${this._busy || !f.name.trim()} @click=${() => this._save()}
>${t("save", L)}</ha-button
>
</div>
</div>
`;
}
protected render() {
const L = this._lang;
if (!this.parts.length && !this.canWrite) return nothing;
return html`
<div class="section-head">
<h3>
<ha-icon icon="mdi:package-variant"></ha-icon>
${t("parts_section", L)} (${this.parts.length})
</h3>
${this.canWrite && !this._editing
? html`<ha-button appearance="plain" @click=${() => this._openAdd()}>
<ha-icon icon="mdi:plus"></ha-icon> ${t("part_add", L)}
</ha-button>`
: nothing}
</div>
${this._error ? html`<div class="error">${this._error}</div>` : nothing}
${this._editing ? this._renderForm() : nothing}
${this.parts.map((part) => this._renderRow(part))}
`;
}
static styles = css`
:host {
display: block;
margin: 12px 0;
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
}
h3 {
display: flex;
align-items: center;
gap: 6px;
margin: 8px 0;
}
.part-row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 4px;
border-bottom: 1px solid var(--divider-color);
}
.part-row.low .part-icon {
color: var(--warning-color, #ff9800);
}
.part-main {
flex: 1;
min-width: 0;
}
.part-name {
font-weight: 500;
display: flex;
align-items: center;
gap: 8px;
}
.part-name a {
color: var(--primary-color);
text-decoration: none;
}
.stock-badge {
font-size: 12px;
padding: 1px 8px;
border-radius: 10px;
background: var(--secondary-background-color);
}
.stock-badge.low {
background: var(--warning-color, #ff9800);
color: var(--text-primary-color, #fff);
}
.stock-badge .threshold {
opacity: 0.7;
}
.part-meta {
font-size: 12px;
color: var(--secondary-text-color);
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.part-meta .loc ha-icon {
--mdc-icon-size: 13px;
}
.restock-input {
width: 64px;
padding: 4px;
border: 1px solid var(--divider-color);
border-radius: 4px;
background: var(--card-background-color);
color: var(--primary-text-color);
}
.restock-input.invalid {
border-color: var(--error-color, #f44336);
}
.docs-open {
color: var(--primary-color);
}
.part-docs {
padding: 0 4px 8px 34px;
border-bottom: 1px solid var(--divider-color);
}
.part-form {
border: 1px solid var(--divider-color);
border-radius: 8px;
padding: 12px;
margin-bottom: 8px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 8px 12px;
}
.form-field {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 12px;
color: var(--secondary-text-color);
}
.form-field input[type="text"],
.form-field input[type="number"] {
padding: 6px;
border: 1px solid var(--divider-color);
border-radius: 4px;
background: var(--card-background-color);
color: var(--primary-text-color);
}
.form-field.checkbox {
flex-direction: row;
align-items: center;
gap: 6px;
align-self: end;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 10px;
}
.error {
color: var(--error-color);
font-size: 13px;
margin: 4px 0;
}
`;
}
customElements.define("maintenance-parts-section", MaintenancePartsSection);
@@ -0,0 +1,247 @@
/** Dialog to save the current panel filters as a named view + manage views.
*
* "Views" are shared, named combinations of the task-list filters (status /
* user / archived) plus sort + group-by. This dialog saves the panel's *current*
* filter state under a name and lists existing views for deletion. Applying a
* view is done from the toolbar dropdown, not here.
*
* Uses a native <input> for the name — <ha-textfield> is not registered in the
* custom-panel context, so it renders without its field (the documented trap).
*/
import { css, html, LitElement, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { t, ensureLocale } from "../styles";
import { describeWsError } from "../ws-errors";
import type { HomeAssistant, SavedView, SavedViewFilters } from "../types";
interface ViewsResponse {
views: SavedView[];
}
export class MaintenanceSavedViewsDialog extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _open = false;
@state() private _busy = false;
@state() private _error = "";
@state() private _name = "";
@state() private _views: SavedView[] = [];
private _filters: SavedViewFilters | null = null;
private _localeReady = false;
private get _lang(): string {
return this.hass?.language || "en";
}
updated(changed: Map<string, unknown>): void {
if (changed.has("hass") && this.hass && !this._localeReady) {
this._localeReady = true;
ensureLocale(this._lang).then(() => this.requestUpdate());
}
}
public async open(currentFilters: SavedViewFilters, views: SavedView[]): Promise<void> {
this._open = true;
this._error = "";
this._name = "";
this._filters = currentFilters;
this._views = views;
}
private _close(): void {
this._open = false;
}
private _emitChanged(views: SavedView[]): void {
this._views = views;
this.dispatchEvent(
new CustomEvent("saved-views-changed", { bubbles: true, composed: true, detail: { views } }),
);
}
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;
}
};
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;
}
};
render() {
if (!this._open) return html``;
const L = this._lang;
return html`
<div class="overlay" @click=${this._close}>
<div class="card" @click=${(e: Event) => e.stopPropagation()}>
<div class="title">${t("views_dialog_title", L)}</div>
<div class="hint">${t("views_dialog_hint", L)}</div>
${this._error ? html`<div class="error">${this._error}</div>` : nothing}
<div class="save-row">
<input
class="name-input"
type="text"
.value=${this._name}
placeholder=${t("views_name_placeholder", L)}
maxlength="60"
@input=${(e: Event) => (this._name = (e.target as HTMLInputElement).value)}
@keydown=${(e: KeyboardEvent) => {
if (e.key === "Enter") this._save();
}}
/>
<ha-button @click=${this._save} .disabled=${!this._name.trim() || this._busy}>
${t("views_save_current", L)}
</ha-button>
</div>
${this._views.length === 0
? html`<div class="empty">${t("views_none_yet", L)}</div>`
: html`
<div class="list">
${this._views.map(
(v) => html`
<div class="row">
<span class="row-name">${v.name}</span>
<ha-icon-button
.path=${"M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19M6,19A2,2 0 0,0 8,21H16A2,2 0 0,0 18,19V7H6V19Z"}
.label=${t("delete", L)}
@click=${() => this._delete(v.id)}
></ha-icon-button>
</div>
`,
)}
</div>
`}
<div class="actions">
<ha-button appearance="plain" @click=${this._close}>${t("close", L)}</ha-button>
</div>
</div>
</div>
`;
}
static styles = css`
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.card {
background: var(--card-background-color, #fff);
color: var(--primary-text-color);
border-radius: 12px;
padding: 20px;
display: flex;
flex-direction: column;
gap: 12px;
min-width: 340px;
max-width: 480px;
width: 90vw;
max-height: 80vh;
overflow: hidden;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
}
.title {
font-size: 18px;
font-weight: 500;
}
.hint {
color: var(--secondary-text-color);
font-size: 13px;
}
.error {
color: var(--error-color, #f44336);
font-size: 13px;
}
.save-row {
display: flex;
gap: 8px;
align-items: center;
}
.name-input {
flex: 1;
padding: 8px 10px;
border: 1px solid var(--divider-color);
border-radius: 6px;
background: var(--card-background-color, #fff);
color: var(--primary-text-color);
font-size: 14px;
}
.empty {
color: var(--secondary-text-color);
font-size: 14px;
padding: 8px 0;
}
.list {
display: flex;
flex-direction: column;
gap: 6px;
overflow-y: auto;
max-height: 50vh;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 6px 8px;
border: 1px solid var(--divider-color);
border-radius: 6px;
}
.row-name {
font-size: 14px;
font-weight: 500;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 8px;
}
`;
}
if (!customElements.get("maintenance-saved-views-dialog")) {
customElements.define("maintenance-saved-views-dialog", MaintenanceSavedViewsDialog);
}
@@ -42,6 +42,7 @@ interface SettingsResponse {
bundling_enabled: boolean;
bundle_threshold: number;
reminder_lead_days: number[];
scope_view_id: string;
};
actions: {
complete_enabled: boolean;
@@ -91,7 +92,7 @@ interface VacationPreviewRow {
// Keep in lockstep with const.BUDGET_CURRENCIES (keys + order). Parity is
// enforced by tests/test_frontend_const_parity.py (drift fails CI).
const CURRENCIES = [
"EUR", "USD", "GBP", "JPY", "CHF", "CAD", "AUD", "CNY", "INR", "BRL",
"EUR", "USD", "GBP", "JPY", "CHF", "CAD", "AUD", "NZD", "CNY", "INR", "BRL",
"CZK", "PLN", "RUB", "SEK", "NOK", "DKK", "UAH",
];
@@ -108,6 +109,7 @@ export class MaintenanceSettingsView extends LitElement {
@state() private _toast = "";
@state() private _testingNotification = false;
@state() private _users: HAUser[] = [];
@state() private _savedViews: Array<{ id: string; name: string }> = [];
// Vacation mode section state (v1.2.0)
@state() private _vacEnabled = false;
@@ -135,6 +137,12 @@ export class MaintenanceSettingsView extends LitElement {
}> = [];
@state() private _qrObjectsLoaded = false;
// Export/Import selective-object picker state
@state() private _exportObjects: Array<{ entry_id: string; name: string; task_count: number }> = [];
@state() private _exportSelectedEntries = new Set<string>();
@state() private _exportObjectsLoaded = false;
@state() private _docArchiveLoading = false;
private _loaded = false;
private _userService: UserService | null = null;
@@ -174,6 +182,15 @@ export class MaintenanceSettingsView extends LitElement {
} catch {
/* ignore */
}
// Saved views feed the notification-scope picker (best-effort).
try {
const v = await this.hass.connection.sendMessagePromise<{ views: Array<{ id: string; name: string }> }>({
type: "maintenance_supporter/views/list",
});
this._savedViews = v.views || [];
} catch {
/* ignore */
}
this._loading = false;
}
@@ -616,6 +633,19 @@ export class MaintenanceSettingsView extends LitElement {
}} />
</label>
<div class="setting-hint">${t("settings_reminder_leads_hint", L)}</div>
<label class="setting-row">
<span class="setting-label">${t("settings_notify_scope", L)}</span>
<select
.value=${n.scope_view_id || ""}
@change=${(e: Event) => this._updateSetting("notify_scope_view_id", (e.target as HTMLSelectElement).value)}
>
<option value="" ?selected=${!n.scope_view_id}>${t("settings_notify_scope_all", L)}</option>
${this._savedViews.map(
(v) => html`<option value=${v.id} ?selected=${n.scope_view_id === v.id}>${v.name}</option>`
)}
</select>
</label>
<div class="setting-hint">${t("settings_notify_scope_hint", L)}</div>
<h4 style="margin: 16px 0 8px; font-size: 14px;">${t("settings_actions", L)}</h4>
<label class="setting-row">
@@ -1250,11 +1280,47 @@ export class MaintenanceSettingsView extends LitElement {
${t("settings_include_history", L)}
</label>
</div>
<div class="settings-actions">
${!this._exportObjectsLoaded
? html`<button @click=${this._loadExportObjects}>${t("settings_export_selection", L)}</button>`
: html`
<details class="qr-filter-panel">
<summary>${t("settings_export_selection", L)}</summary>
<div class="qr-object-list">
${this._exportObjects.length === 0
? html`<div class="hint">${t("no_objects", L)}</div>`
: this._exportObjects.map((obj) => html`
<label class="qr-object-row">
<input type="checkbox"
.checked=${this._exportSelectedEntries.size === 0 || this._exportSelectedEntries.has(obj.entry_id)}
@change=${(e: Event) => this._toggleExportObject(obj.entry_id, (e.target as HTMLInputElement).checked)} />
<span>${obj.name}</span>
<span class="qr-task-count">${obj.task_count}</span>
</label>
`)}
</div>
</details>
`}
</div>
<div class="settings-actions">
<button @click=${this._exportJson}>${t("settings_export_json", L)}</button>
<button @click=${this._exportYaml}>${t("settings_export_yaml", L)}</button>
<button @click=${this._exportCsv}>${t("settings_export_csv", L)}</button>
</div>
<div class="settings-actions docs-archive-block">
<h4>${t("settings_docs_archive", L)}</h4>
<p class="section-desc">${t("settings_docs_archive_hint", L)}</p>
<div class="settings-actions">
<button ?disabled=${this._docArchiveLoading} @click=${this._exportDocsArchive}>
${t("settings_docs_export_btn", L)}
</button>
<button ?disabled=${this._docArchiveLoading} @click=${this._triggerDocsArchiveImport}>
${this._docArchiveLoading ? "…" : t("settings_docs_import_btn", L)}
</button>
<input class="docs-archive-file" type="file" accept=".zip" hidden
@change=${this._importDocsArchive} />
</div>
</div>
<div class="import-section">
<textarea class="import-area" .value=${this._importCsv}
placeholder=${t("settings_import_placeholder", L)}
@@ -1273,12 +1339,44 @@ export class MaintenanceSettingsView extends LitElement {
// --- Export / Import actions ---
private get _selectedEntryIds(): string[] | undefined {
return this._exportSelectedEntries.size ? [...this._exportSelectedEntries] : undefined;
}
private async _loadExportObjects(): Promise<void> {
try {
const result = await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/objects",
}) as { objects: Array<{ entry_id: string; object: { name: string }; tasks: unknown[] }> };
this._exportObjects = (result.objects || []).map((o) => ({
entry_id: o.entry_id,
name: o.object.name,
task_count: (o.tasks || []).length,
})).sort((a, b) => a.name.localeCompare(b.name));
this._exportObjectsLoaded = true;
} catch {
this._showToast(t("action_error", this._lang));
}
}
private _toggleExportObject(entryId: string, on: boolean): void {
const next = new Set(this._exportSelectedEntries);
if (next.size === 0) {
for (const o of this._exportObjects) next.add(o.entry_id);
}
if (on) next.add(entryId); else next.delete(entryId);
if (next.size === this._exportObjects.length) next.clear();
this._exportSelectedEntries = next;
}
private async _exportJson(): Promise<void> {
try {
const ids = this._selectedEntryIds;
const result = await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/export",
format: "json",
include_history: this._includeHistory,
...(ids ? { entry_ids: ids } : {}),
}) as { data: string };
const ts = new Date().toISOString().slice(0, 10);
this._downloadFile(result.data, `maintenance_export_${ts}.json`, "application/json");
@@ -1290,10 +1388,12 @@ export class MaintenanceSettingsView extends LitElement {
private async _exportYaml(): Promise<void> {
try {
const ids = this._selectedEntryIds;
const result = await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/export",
format: "yaml",
include_history: this._includeHistory,
...(ids ? { entry_ids: ids } : {}),
}) as { data: string };
const ts = new Date().toISOString().slice(0, 10);
this._downloadFile(result.data, `maintenance_export_${ts}.yaml`, "application/yaml");
@@ -1305,8 +1405,10 @@ export class MaintenanceSettingsView extends LitElement {
private async _exportCsv(): Promise<void> {
try {
const ids = this._selectedEntryIds;
const result = await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/csv/export",
...(ids ? { entry_ids: ids } : {}),
}) as { csv: string };
const ts = new Date().toISOString().slice(0, 10);
this._downloadFile(result.csv, `maintenance_export_${ts}.csv`, "text/csv");
@@ -1340,6 +1442,66 @@ export class MaintenanceSettingsView extends LitElement {
this._importLoading = false;
}
// --- Documents archive (ZIP with file contents) ---
private async _exportDocsArchive(): Promise<void> {
this._docArchiveLoading = true;
try {
const raw = this._selectedEntryIds;
const q = raw ? `?entry_ids=${encodeURIComponent(raw.join(","))}` : "";
const signed = await this.hass.connection.sendMessagePromise<{ path: string }>({
type: "auth/sign_path",
path: `/api/maintenance_supporter/documents/archive${q}`,
expires: 300,
});
const a = document.createElement("a");
a.href = signed.path;
a.download = "maintenance-documents.zip";
document.body.appendChild(a);
a.click();
a.remove();
} catch {
this._showToast(t("action_error", this._lang));
}
this._docArchiveLoading = false;
}
private _triggerDocsArchiveImport(): void {
const input = this.renderRoot.querySelector(".docs-archive-file") as HTMLInputElement | null;
input?.click();
}
private async _importDocsArchive(e: Event): Promise<void> {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
this._docArchiveLoading = true;
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));
}
input.value = "";
this._docArchiveLoading = false;
}
// --- Styles ---
static styles = css`
@@ -100,6 +100,8 @@ export class MaintenanceTaskDialog extends LitElement {
@property({ type: Boolean, attribute: "schedule-time-enabled" }) public scheduleTimeEnabled = false;
@property({ type: Boolean, attribute: "completion-actions-enabled" }) public completionActionsEnabled = false;
@property({ type: Number, attribute: "default-warning-days" }) public defaultWarningDays = 7;
/** The object's spare parts — offered as "consumes parts" checkboxes. */
@state() private parts: Array<{ id: string; name: string; unit?: string }> = [];
@state() private _open = false;
@state() private _loading = false;
@state() private _error = "";
@@ -171,6 +173,8 @@ export class MaintenanceTaskDialog extends LitElement {
@state() private _nfcTagId = "";
// v2.20 (#83): unit for `reading`-type tasks ("kWh", "m³", ...)
@state() private _readingUnit = "";
@state() private _consumesParts: Record<string, number> = {};
@state() private _partsLoadFailed = false;
@state() private _availableTags: Array<{id: string; name: string}> = [];
// User assignment
@@ -230,7 +234,7 @@ export class MaintenanceTaskDialog extends LitElement {
this._objectChoices = [];
}
this._resetFields();
await Promise.all([this._loadUsers(), this._loadTags()]);
await Promise.all([this._loadUsers(), this._loadTags(), this._loadParts()]);
this._open = true;
}
@@ -280,6 +284,7 @@ export class MaintenanceTaskDialog extends LitElement {
this._lastPerformed = task.last_performed || "";
this._nfcTagId = task.nfc_tag_id || "";
this._readingUnit = task.reading_unit || "";
this._consumesParts = Object.fromEntries((task.consumes_parts || []).map((l) => [l.part_id, l.quantity]));
this._responsibleUserId = task.responsible_user_id || null;
this._assigneePool = [...(task.assignee_pool || [])];
this._rotationStrategy = task.rotation_strategy || "";
@@ -346,7 +351,7 @@ export class MaintenanceTaskDialog extends LitElement {
this._fetchEntityAttributes(this._triggerEntityId);
}
await Promise.all([this._loadUsers(), this._loadTags()]);
await Promise.all([this._loadUsers(), this._loadTags(), this._loadParts()]);
this._open = true;
}
@@ -380,6 +385,7 @@ export class MaintenanceTaskDialog extends LitElement {
this._lastPerformed = "";
this._nfcTagId = "";
this._readingUnit = "";
this._consumesParts = {};
this._responsibleUserId = null;
this._assigneePool = [];
this._rotationStrategy = "";
@@ -662,6 +668,27 @@ export class MaintenanceTaskDialog extends LitElement {
`;
}
private async _loadParts(): Promise<void> {
// The object's parts back the "consumes parts" checkboxes. Self-loaded so
// every dialog opener (panel, card, task detail) gets them without
// threading props through.
this.parts = [];
if (!this._entryId) return;
try {
const result = (await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/object",
entry_id: this._entryId,
})) as { parts?: Array<{ id: string; name: string; unit?: string }> };
this.parts = result.parts || [];
this._partsLoadFailed = false;
} catch {
// Surface the failure: an empty-but-failed load must not just hide the
// "consumes parts" section as if the object had no parts.
this.parts = [];
this._partsLoadFailed = true;
}
}
private async _loadTags(): Promise<void> {
try {
const result = await this.hass.connection.sendMessagePromise({
@@ -762,6 +789,12 @@ export class MaintenanceTaskDialog extends LitElement {
data.last_performed = this._lastPerformed || null;
data.nfc_tag_id = this._nfcTagId || null;
data.reading_unit = this._readingUnit.trim() || null;
if (this.parts.length) {
data.consumes_parts = Object.entries(this._consumesParts).map(([part_id, quantity]) => ({
part_id,
quantity,
}));
}
data.responsible_user_id = this._responsibleUserId;
data.assignee_pool = this._assigneePool;
data.rotation_strategy =
@@ -978,6 +1011,7 @@ export class MaintenanceTaskDialog extends LitElement {
`
}
${this._renderTriggerTypeFields()}
${this._renderTriggerLiveHint()}
`}
<label>
<input
@@ -1311,6 +1345,81 @@ export class MaintenanceTaskDialog extends LitElement {
></ms-textfield>`;
}
/** Live "what happens next" hint for sensor-based triggers.
*
* Reads the bound entity's CURRENT state client-side (the dialog already
* holds `hass`) and spells out the trigger semantics against it — clearing
* the most common usage-meter confusion: a delta counter counts from the
* sensor's current reading (not from zero) and restarts after each
* completion. Renders nothing when there's no entity/state to read.
*/
private _renderTriggerLiveHint() {
if (this._triggerType === "compound") return nothing;
const entityId = this._triggerEntityId || this._triggerEntityIds[0];
if (!entityId || !this.hass?.states) return nothing;
const st = this.hass.states[entityId];
if (!st) return nothing;
const L = this._lang;
const unitAttr = st.attributes?.unit_of_measurement;
const unit = typeof unitAttr === "string" && unitAttr ? ` ${unitAttr}` : "";
const raw = this._triggerAttribute
? st.attributes?.[this._triggerAttribute]
: st.state;
const num = typeof raw === "number" ? raw : parseFloat(String(raw));
const hasNum = raw !== "unknown" && raw !== "unavailable" && raw != null && !isNaN(num);
const fmt = (v: number) => (Number.isInteger(v) ? String(v) : String(Math.round(v * 10) / 10));
const parts: string[] = [];
if (this._triggerType === "threshold") {
const above = parseFloat(this._triggerAbove);
const below = parseFloat(this._triggerBelow);
if (isNaN(above) && isNaN(below)) return nothing;
if (hasNum) parts.push(t("trigger_hint_now", L).replace("{value}", fmt(num) + unit));
if (!isNaN(above)) parts.push(t("trigger_hint_above", L).replace("{target}", fmt(above) + unit));
if (!isNaN(below)) parts.push(t("trigger_hint_below", L).replace("{target}", fmt(below) + unit));
} else if (this._triggerType === "counter") {
const target = parseFloat(this._triggerTargetValue);
if (isNaN(target)) return nothing;
if (this._triggerDeltaMode) {
if (this._taskId) {
// Editing: the baseline is the reading at the last completion (or
// task creation), not the current value — don't imply otherwise.
parts.push(t("trigger_hint_counter_delta_edit", L).replace("{target}", fmt(target) + unit));
} else if (hasNum) {
parts.push(
t("trigger_hint_counter_delta", L)
.replace("{value}", fmt(num) + unit)
.replace("{due}", fmt(num + target) + unit)
.replace("{target}", fmt(target) + unit),
);
} else {
parts.push(t("trigger_hint_counter_delta_edit", L).replace("{target}", fmt(target) + unit));
}
} else {
if (hasNum) parts.push(t("trigger_hint_now", L).replace("{value}", fmt(num) + unit));
parts.push(t("trigger_hint_counter_abs", L).replace("{target}", fmt(target) + unit));
}
} else if (this._triggerType === "runtime") {
const hours = parseFloat(this._triggerRuntimeHours);
if (isNaN(hours)) return nothing;
parts.push(t("trigger_hint_runtime", L).replace("{hours}", fmt(hours)));
parts.push(t("trigger_hint_state_now", L).replace("{value}", String(st.state)));
} else if (this._triggerType === "state_change") {
const n = parseInt(this._triggerTargetChanges, 10) || 1;
const to = this._triggerToState.trim();
parts.push(
(to
? t("trigger_hint_state_change_to", L).replace("{state}", to)
: t("trigger_hint_state_change", L)
).replace("{count}", String(n)),
);
parts.push(t("trigger_hint_state_now", L).replace("{value}", String(st.state)));
}
if (!parts.length) return nothing;
return html`<div class="trigger-live-hint">${parts.join(" ")}</div>`;
}
private _renderTriggerTypeFields() {
const L = this._lang;
if (this._triggerType === "threshold") {
@@ -1407,7 +1516,11 @@ export class MaintenanceTaskDialog extends LitElement {
<label>${t("object", L)}</label>
<select
.value=${this._entryId}
@change=${(e: Event) => (this._entryId = (e.target as HTMLSelectElement).value)}
@change=${(e: Event) => {
this._entryId = (e.target as HTMLSelectElement).value;
this._consumesParts = {};
this._loadParts();
}}
>
${this._objectChoices.map(
(o) => html`<option value=${o.entry_id} ?selected=${o.entry_id === this._entryId}>${o.name}</option>`
@@ -1442,6 +1555,49 @@ export class MaintenanceTaskDialog extends LitElement {
<div class="field-help">${t("reading_unit_help", L)}</div>
`
: nothing}
${this._partsLoadFailed
? html`<div class="field-help parts-load-failed">${t("parts_load_failed", L)}</div>`
: nothing}
${this.parts.length
? html`
<div class="field">
<label>${t("consumes_parts_label", L)}</label>
${this.parts.map((part) => {
const qty = this._consumesParts[part.id];
return html`
<div class="consumes-row">
<label class="consumes-check">
<input
type="checkbox"
.checked=${qty !== undefined}
@change=${(e: Event) => {
const next = { ...this._consumesParts };
if ((e.target as HTMLInputElement).checked) next[part.id] = next[part.id] || 1;
else delete next[part.id];
this._consumesParts = next;
}}
/>
<span>${part.name}${part.unit ? ` (${part.unit})` : ""}</span>
</label>
${qty !== undefined
? html`<input
class="consumes-qty"
type="number"
min="1"
max="999"
.value=${String(qty)}
@input=${(e: Event) => {
const v = parseInt((e.target as HTMLInputElement).value, 10);
this._consumesParts = { ...this._consumesParts, [part.id]: Number.isFinite(v) && v >= 1 ? v : 1 };
}}
/>`
: nothing}
</div>
`;
})}
</div>
`
: nothing}
<div class="select-row">
<label>${t("priority", L)}</label>
<select
@@ -1769,10 +1925,41 @@ export class MaintenanceTaskDialog extends LitElement {
resize: vertical;
box-sizing: border-box;
}
.consumes-row {
display: flex;
align-items: center;
gap: 8px;
padding: 2px 0;
}
.consumes-check {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
}
.consumes-qty {
width: 64px;
padding: 4px;
border: 1px solid var(--divider-color);
border-radius: 4px;
background: var(--card-background-color);
color: var(--primary-text-color);
}
.field-help {
font-size: 12px;
color: var(--secondary-text-color);
}
/* Live computed trigger hint — reads the bound sensor and explains what
happens next. Info-accented so it reads as guidance, not an error. */
.trigger-live-hint {
font-size: 12px;
color: var(--secondary-text-color);
border-left: 3px solid var(--info-color, #2196f3);
background: rgba(33, 150, 243, 0.08);
border-radius: 0 6px 6px 0;
padding: 6px 10px;
margin: 4px 0;
}
.field-help a,
.link-button {
background: none;
@@ -1,10 +1,12 @@
/** Documents linked to a maintenance task.
/** Documents linked to a maintenance task — or (v2.26) to a spare part.
*
* A filtered view over the object's document pool via each doc's `task_ids`:
* link/unlink existing object documents to the task and open/download them. Full
* upload + management lives at the object level (documents-section); this keeps
* the manual / spare-parts list right where the maintenance work happens. Hides
* itself entirely when the object has no documents at all.
* A filtered view over the object's document pool via each doc's `task_ids`
* (task mode) or `part_ids` (part mode, set `partId` instead of `taskId`):
* link/unlink existing object documents and open/download them. Full upload +
* management lives at the object level (documents-section); this keeps the
* manual / datasheet right where the work happens. Per-task PDF page hints
* exist only in task mode. Hides itself entirely when the object has no
* documents at all.
*/
import { LitElement, html, css, nothing } from "lit";
@@ -26,6 +28,7 @@ interface Doc {
tags?: string[];
task_ids?: string[];
task_pages?: Record<string, number>;
part_ids?: string[];
}
const CATEGORIES = ["manual", "warranty", "invoice", "spare_parts", "photo", "other"] as const;
@@ -41,7 +44,9 @@ const CATEGORY_ICONS: Record<string, string> = {
export class MaintenanceTaskDocuments extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public entryId!: string;
@property({ attribute: false }) public taskId!: string;
@property({ attribute: false }) public taskId?: string;
/** Part mode: link docs to this spare part (via `part_ids`) instead of a task. */
@property({ attribute: false }) public partId?: string;
@property({ type: Boolean }) public canWrite = false;
@state() private _docs: Doc[] = [];
@@ -57,14 +62,24 @@ export class MaintenanceTaskDocuments extends LitElement {
return this.hass?.language || "en";
}
/** The id linked docs reference — a task id or (part mode) a part id. */
private get _refId(): string {
return this.partId || this.taskId || "";
}
/** The doc metadata field the link lives in. */
private get _linkField(): "task_ids" | "part_ids" {
return this.partId ? "part_ids" : "task_ids";
}
updated(changed: Map<string, unknown>): void {
super.updated(changed);
if (this.hass && !this._localeReady) {
this._localeReady = true;
void ensureLocale(this._lang).then(() => this.requestUpdate());
}
const key = `${this.entryId}|${this.taskId}`;
if (this.hass && this.entryId && this.taskId && this._loadedKey !== key) {
const key = `${this.entryId}|${this._refId}`;
if (this.hass && this.entryId && this._refId && this._loadedKey !== key) {
this._loadedKey = key;
void this._load();
}
@@ -85,22 +100,26 @@ export class MaintenanceTaskDocuments extends LitElement {
}
}
private _links(doc: Doc): string[] {
return doc[this._linkField] || [];
}
private _linked(): Doc[] {
return this._docs.filter((d) => (d.task_ids || []).includes(this.taskId));
return this._docs.filter((d) => this._links(d).includes(this._refId));
}
private _available(): Doc[] {
return this._docs.filter((d) => !(d.task_ids || []).includes(this.taskId));
return this._docs.filter((d) => !this._links(d).includes(this._refId));
}
private async _setTaskIds(doc: Doc, taskIds: string[]): Promise<void> {
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,
task_ids: taskIds,
[this._linkField]: ids,
});
await this._load();
} catch (e) {
@@ -114,20 +133,21 @@ export class MaintenanceTaskDocuments extends LitElement {
const doc = this._docs.find((d) => d.id === this._attachId);
if (!doc) return;
this._attachId = "";
void this._setTaskIds(doc, [...(doc.task_ids || []), this.taskId]);
void this._setLinks(doc, [...this._links(doc), this._refId]);
}
private _unlink(doc: Doc): void {
void this._setTaskIds(doc, (doc.task_ids || []).filter((x) => x !== this.taskId));
void this._setLinks(doc, this._links(doc).filter((x) => x !== this._refId));
}
private _isPdf(doc: Doc): boolean {
return doc.mime === "application/pdf" || (doc.filename || "").toLowerCase().endsWith(".pdf");
}
/** The page this doc should open at for the current task, if set (PDFs only). */
/** The page this doc should open at for the current task, if set (PDFs only;
* page hints are a task-mode concept — none in part mode). */
private _pageFor(doc: Doc): number | undefined {
return this._isPdf(doc) ? doc.task_pages?.[this.taskId] : undefined;
return this._isPdf(doc) && this.taskId ? doc.task_pages?.[this.taskId] : undefined;
}
private async _open(doc: Doc): Promise<void> {
@@ -157,6 +177,7 @@ export class MaintenanceTaskDocuments extends LitElement {
/** 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 {
@@ -197,7 +218,7 @@ export class MaintenanceTaskDocuments extends LitElement {
<h3><ha-icon icon="mdi:paperclip"></ha-icon> ${t("documents", L)} (${linked.length})</h3>
${this._error ? html`<div class="tdoc-error">${this._error}</div>` : nothing}
${linked.length === 0
? html`<div class="tdoc-empty">${t("doc_task_none", L)}</div>`
? html`<div class="tdoc-empty">${t(this.partId ? "doc_part_none" : "doc_task_none", L)}</div>`
: html`<div class="tdoc-list">${linked.map((d) => this._renderRow(d, L))}</div>`}
${this.canWrite && available.length
? html`<div class="tdoc-attach">
@@ -223,7 +244,7 @@ export class MaintenanceTaskDocuments extends LitElement {
private _renderRow(doc: Doc, L: string) {
const isFile = doc.kind === "file";
const isPdf = this._isPdf(doc);
const page = doc.task_pages?.[this.taskId];
const page = this._pageFor(doc);
const cat = (doc.tags || []).find((x) => (CATEGORIES as readonly string[]).includes(x)) || "other";
const meta = isFile ? formatBytes(doc.size) : t("doc_link_badge", L);
return html`
@@ -247,7 +268,7 @@ export class MaintenanceTaskDocuments extends LitElement {
${meta}${page ? html` · <span class="tdoc-pagetag">${t("doc_page", L)} ${page}</span>` : nothing}
</div>
</div>
${this.canWrite && isPdf
${this.canWrite && isPdf && this.taskId
? html`<input
class="tdoc-page"
type="number"
@@ -0,0 +1,19 @@
/** localStorage keys for panel UI preferences — single source of truth.
*
* The VALUES are a compatibility contract: they are what users' browsers
* already have stored, so they must never change (the mixed msp-/full-name
* prefixes are historical and stay). Centralizing the names just makes a typo
* in one call site impossible — a DRY audit (2026-07-10) found every key
* duplicated inline at its get/set pair.
*/
export const LS_KEYS = {
overviewTab: "msp-overview-tab",
collapsedSections: "msp-collapsed-sections",
chartRange: "msp-chart-range",
chartHideOutliers: "msp-chart-hide-outliers",
taskSort: "maintenance_supporter_sort",
objectSort: "maintenance_supporter_object_sort",
groupBy: "maintenance_supporter_groupby",
objectView: "maintenance_supporter_object_view",
} as const;
@@ -0,0 +1,11 @@
/** Shared URL-safety guard for rendering user-supplied links.
*
* A user-entered URL (object/task documentation, spare-part shopping link,
* document weblink) must only ever be linkified when it is an absolute http(s)
* URL — never `javascript:`/`data:`/protocol-relative — else it is a stored-XSS
* vector. The `X && /^https?:\/\//i.test(X)` idiom was inlined at ~8 render
* sites; one forgotten copy is a hole, so it lives here once.
*/
export function isSafeHttpUrl(url: string | null | undefined): url is string {
return !!url && /^https?:\/\//i.test(url);
}
@@ -25,6 +25,7 @@ export interface WorksheetLabels {
priority: string;
checklist: string;
notes: string;
parts: string; // "Required parts"
scanView: string; // "Scan to open the task"
scanComplete: string; // "Scan to complete"
manualExcerpt: string; // "Manual excerpt"
@@ -59,6 +60,7 @@ export function buildTaskWorksheetHtml(
qrCompleteDataUri: string | null,
excerpt: WorksheetExcerpt | null,
nowIso: string,
partsLines: string[] = [],
): string {
const meta: Array<[string, string]> = [
[L.object, esc(objectName)],
@@ -127,6 +129,7 @@ export function buildTaskWorksheetHtml(
${meta.map(([k, v]) => `<tr><td>${esc(k)}</td><td>${v}</td></tr>`).join("")}
</table>
${checklist ? `<h2>${esc(L.checklist)}</h2><ul class="check">${checklist}</ul>` : ""}
${partsLines.length ? `<h2>${esc(L.parts)}</h2><ul class="check">${partsLines.map((line) => `<li><span class="box"></span>${esc(line)}</li>`).join("")}</ul>` : ""}
${task.notes ? `<h2>${esc(L.notes)}</h2><div class="notes">${esc(task.notes)}</div>` : ""}
${excerpt ? `<h2>${esc(L.manualExcerpt)}</h2>
<div class="excerpt">${esc(excerpt.title)}${esc(L.pages)} ${excerpt.startPage}${excerpt.endPage}:
@@ -706,5 +706,65 @@
"series_end_after_count": "Po počtu opakování",
"series_end_until": "K datu",
"series_end_count_label": "Počet opakování",
"series_end_until_label": "Datum konce"
"series_end_until_label": "Datum konce",
"parts_section": "Díly a spotřební materiál",
"part_add": "Přidat díl",
"part_name": "Název",
"part_vendor": "Výrobce",
"part_storage_location": "Místo uložení",
"part_product_url": "URL produktu",
"part_unit": "Jednotka",
"part_cost": "Cena za kus",
"part_stock": "Zásoba",
"part_reorder_threshold": "Práh doobjednání",
"part_restock_quantity": "Množství doplnění",
"part_auto_buy": "Úkol nákupu při nízké zásobě",
"part_restock": "Upravit zásobu",
"restock_quantity_label": "Zakoupené množství",
"consumes_parts_label": "Spotřebovává díly",
"parts_load_failed": "Nepodařilo se načíst díly tohoto objektu — možnosti spotřeby dílů nyní nejsou k dispozici.",
"settings_export_selection": "Omezit na vybrané objekty (volitelné)",
"settings_docs_archive": "Archiv dokumentů (se soubory)",
"settings_docs_archive_hint": "Exporty JSON/YAML/CSV obsahují jen nastavení. Tento ZIP obsahuje obsah nahraných souborů, aby obnovení bylo úplné.",
"settings_docs_export_btn": "Stáhnout ZIP dokumentů",
"settings_docs_import_btn": "Obnovit ZIP dokumentů",
"settings_docs_import_success": "Obnoveno: {blobs} souborů, {docs} dokumentů",
"adopt_problem_button": "Převzít senzory problémů",
"adopt_problem_title": "Převzít senzory problémů",
"adopt_problem_hint": "Změní senzory problémů HA (chyby tiskárny, upozornění filtru, slabá baterie) na úkoly údržby, které se spustí, dokud je problém aktivní, a samy se dokončí, když se vyřeší.",
"adopt_problem_none": "Nebyly nalezeny žádné dosud nesledované senzory problémů.",
"adopt_problem_active": "aktivní",
"adopt_problem_ok": "ok",
"adopt_problem_new_object": "(nový)",
"adopt_problem_adopt": "Převzít vybrané",
"adopt_problem_done": "Převzato {tasks} senzorů problémů",
"views_label": "Zobrazení",
"views_none": "— Žádné zobrazení —",
"views_manage": "Uložit / spravovat zobrazení",
"views_dialog_title": "Uložená zobrazení",
"views_dialog_hint": "Uložte aktuální filtry jako pojmenované zobrazení, které může použít každý.",
"views_name_placeholder": "Název zobrazení",
"views_save_current": "Uložit aktuální filtry",
"views_none_yet": "Zatím žádná uložená zobrazení.",
"close": "Zavřít",
"trigger_hint_now": "Senzor nyní ukazuje {value}.",
"trigger_hint_above": "Úkol se spustí, jakmile hodnota překročí {target}.",
"trigger_hint_below": "Spustí se, jakmile hodnota klesne pod {target}.",
"trigger_hint_counter_delta": "Počítá od aktuální hodnoty ({value}): splatné při {due} (+{target}); počítadlo se po každém dokončení restartuje.",
"trigger_hint_counter_delta_edit": "Počítá spotřebu od posledního dokončení: splatné po +{target}; počítadlo se po každém dokončení restartuje.",
"trigger_hint_counter_abs": "Úkol bude splatný, jakmile senzor dosáhne {target}.",
"trigger_hint_runtime": "Úkol bude splatný po {hours} h nasčítaného provozu; počítadlo se po každém dokončení restartuje.",
"trigger_hint_state_change": "Úkol bude splatný po {count} změně/změnách stavu.",
"trigger_hint_state_change_to": "Úkol bude splatný po {count} změně/změnách na „{state}“.",
"trigger_hint_state_now": "Aktuální stav: {value}.",
"adopt_problem_part": "Používá díl: {name}",
"label_filter": "Štítek",
"all_labels": "Všechny štítky",
"settings_notify_scope": "Upozorňovat jen pro zobrazení",
"settings_notify_scope_all": "Všechny úkoly",
"settings_notify_scope_hint": "Připomenutí posílají jen úkoly odpovídající filtrům štítku/uživatele zvoleného zobrazení. Stav, řazení a seskupení zobrazení se zde ignorují.",
"card_saved_view": "Uložené zobrazení",
"card_saved_view_none": "Žádné",
"card_saved_view_help": "Použije filtry stavu, uživatele a štítku zobrazení navíc k výše uvedeným filtrům. Řazení a seskupení zobrazení jsou nastavení panelu a na kartě se nepoužijí.",
"doc_part_none": "K tomuto dílu nejsou připojeny žádné dokumenty."
}
@@ -706,5 +706,65 @@
"series_end_after_count": "Efter et antal gange",
"series_end_until": "På en dato",
"series_end_count_label": "Antal gange",
"series_end_until_label": "Slutdato"
"series_end_until_label": "Slutdato",
"parts_section": "Dele & forbrugsvarer",
"part_add": "Tilføj del",
"part_name": "Navn",
"part_vendor": "Producent",
"part_storage_location": "Opbevaringssted",
"part_product_url": "Produkt-URL",
"part_unit": "Enhed",
"part_cost": "Stykpris",
"part_stock": "Lager",
"part_reorder_threshold": "Genbestillingsgrænse",
"part_restock_quantity": "Genopfyldningsmængde",
"part_auto_buy": "Købsopgave ved lav beholdning",
"part_restock": "Justér lager",
"restock_quantity_label": "Købt mængde",
"consumes_parts_label": "Forbruger dele",
"parts_load_failed": "Kunne ikke indlæse objektets reservedele — forbrugsindstillingerne er ikke tilgængelige lige nu.",
"settings_export_selection": "Begræns til valgte objekter (valgfrit)",
"settings_docs_archive": "Dokumentarkiv (med filer)",
"settings_docs_archive_hint": "JSON/YAML/CSV-eksporter indeholder kun indstillinger. Denne ZIP indeholder de uploadede filers indhold, så en gendannelse er fuldstændig.",
"settings_docs_export_btn": "Download dokument-ZIP",
"settings_docs_import_btn": "Gendan dokument-ZIP",
"settings_docs_import_success": "Gendannet: {blobs} filer, {docs} dokumenter",
"adopt_problem_button": "Adopter problemsensorer",
"adopt_problem_title": "Adopter problemsensorer",
"adopt_problem_hint": "Gør HA-problemsensorer (printerfejl, filteradvarsler, lavt batteri) til vedligeholdelsesopgaver, der udløses, mens problemet er aktivt, og afsluttes selv, når det er løst.",
"adopt_problem_none": "Ingen problemsensorer fundet, der ikke allerede spores.",
"adopt_problem_active": "aktiv",
"adopt_problem_ok": "ok",
"adopt_problem_new_object": "(ny)",
"adopt_problem_adopt": "Adopter valgte",
"adopt_problem_done": "Adopterede {tasks} problemsensor(er)",
"views_label": "Visninger",
"views_none": "— Ingen visning —",
"views_manage": "Gem / administrer visninger",
"views_dialog_title": "Gemte visninger",
"views_dialog_hint": "Gem de aktuelle filtre som en navngiven visning, som alle kan genbruge.",
"views_name_placeholder": "Visningsnavn",
"views_save_current": "Gem aktuelle filtre",
"views_none_yet": "Ingen gemte visninger endnu.",
"close": "Luk",
"trigger_hint_now": "Sensoren viser {value} lige nu.",
"trigger_hint_above": "Opgaven udløses, når værdien stiger over {target}.",
"trigger_hint_below": "Den udløses, når værdien falder under {target}.",
"trigger_hint_counter_delta": "Tæller fra den aktuelle aflæsning ({value}): forfalder ved {due} (+{target}); tælleren starter forfra efter hver fuldførelse.",
"trigger_hint_counter_delta_edit": "Tæller forbruget siden sidste fuldførelse: forfalder efter +{target}; tælleren starter forfra efter hver fuldførelse.",
"trigger_hint_counter_abs": "Opgaven forfalder, når sensoren når {target}.",
"trigger_hint_runtime": "Opgaven forfalder efter {hours} t akkumuleret driftstid; tælleren starter forfra efter hver fuldførelse.",
"trigger_hint_state_change": "Opgaven forfalder efter {count} tilstandsskift.",
"trigger_hint_state_change_to": "Opgaven forfalder efter {count} skift til ”{state}”.",
"trigger_hint_state_now": "Aktuel tilstand: {value}.",
"adopt_problem_part": "Bruger reservedel: {name}",
"label_filter": "Etiket",
"all_labels": "Alle etiketter",
"settings_notify_scope": "Underret kun for visning",
"settings_notify_scope_all": "Alle opgaver",
"settings_notify_scope_hint": "Kun opgaver, der matcher den valgte visnings etiket-/brugerfiltre, sender påmindelser. Visningens status, sortering og gruppering ignoreres her.",
"card_saved_view": "Gemt visning",
"card_saved_view_none": "Ingen",
"card_saved_view_help": "Anvender visningens status-, bruger- og etiketfiltre oven på filtrene ovenfor. Visningens sortering og gruppering er panelvisningsindstillinger og anvendes ikke på kortet.",
"doc_part_none": "Ingen dokumenter knyttet til denne del."
}
@@ -706,5 +706,65 @@
"series_end_after_count": "Nach Anzahl",
"series_end_until": "An einem Datum",
"series_end_count_label": "Anzahl",
"series_end_until_label": "Enddatum"
"series_end_until_label": "Enddatum",
"parts_section": "Teile & Verbrauchsmaterial",
"part_add": "Teil hinzufügen",
"part_name": "Name",
"part_vendor": "Hersteller",
"part_storage_location": "Lagerort",
"part_product_url": "Produkt-URL",
"part_unit": "Einheit",
"part_cost": "Stückpreis",
"part_stock": "Bestand",
"part_reorder_threshold": "Nachkauf-Schwelle",
"part_restock_quantity": "Auffüllmenge",
"part_auto_buy": "Kauf-Task bei Niedrigbestand",
"part_restock": "Bestand anpassen",
"restock_quantity_label": "Gekaufte Menge",
"consumes_parts_label": "Verbraucht Teile",
"parts_load_failed": "Die Teile dieses Objekts konnten nicht geladen werden — die Teileverbrauch-Optionen sind gerade nicht verfügbar.",
"settings_export_selection": "Auf ausgewählte Objekte beschränken (optional)",
"settings_docs_archive": "Dokumentenarchiv (mit Dateien)",
"settings_docs_archive_hint": "Die JSON/YAML/CSV-Exporte enthalten nur Einstellungen. Dieses ZIP enthält die hochgeladenen Dateiinhalte, sodass eine Wiederherstellung vollständig ist.",
"settings_docs_export_btn": "Dokumente-ZIP herunterladen",
"settings_docs_import_btn": "Dokumente-ZIP wiederherstellen",
"settings_docs_import_success": "Wiederhergestellt: {blobs} Dateien, {docs} Dokumente",
"adopt_problem_button": "Problem-Sensoren übernehmen",
"adopt_problem_title": "Problem-Sensoren übernehmen",
"adopt_problem_hint": "Verwandelt HA-Problem-Sensoren (Druckerfehler, Filterwarnungen, schwache Batterie) in Wartungsaufgaben, die auslösen, solange das Problem aktiv ist, und sich selbst erledigen, wenn es behoben ist.",
"adopt_problem_none": "Keine noch nicht erfassten Problem-Sensoren gefunden.",
"adopt_problem_active": "aktiv",
"adopt_problem_ok": "ok",
"adopt_problem_new_object": "(neu)",
"adopt_problem_adopt": "Auswahl übernehmen",
"adopt_problem_done": "{tasks} Problem-Sensor(en) übernommen",
"views_label": "Ansichten",
"views_none": "— Keine Ansicht —",
"views_manage": "Ansichten speichern/verwalten",
"views_dialog_title": "Gespeicherte Ansichten",
"views_dialog_hint": "Aktuelle Filter als benannte Ansicht speichern, die alle nutzen können.",
"views_name_placeholder": "Name der Ansicht",
"views_save_current": "Aktuelle Filter speichern",
"views_none_yet": "Noch keine gespeicherten Ansichten.",
"close": "Schließen",
"trigger_hint_now": "Der Sensor zeigt aktuell {value}.",
"trigger_hint_above": "Die Aufgabe wird ausgelöst, sobald der Wert über {target} steigt.",
"trigger_hint_below": "Sie wird ausgelöst, sobald der Wert unter {target} fällt.",
"trigger_hint_counter_delta": "Zählt ab dem aktuellen Stand ({value}): fällig bei {due} (+{target}); der Zähler startet nach jedem Abschluss neu.",
"trigger_hint_counter_delta_edit": "Zählt den Verbrauch seit dem letzten Abschluss: fällig nach +{target}; der Zähler startet nach jedem Abschluss neu.",
"trigger_hint_counter_abs": "Die Aufgabe wird fällig, sobald der Sensor {target} erreicht.",
"trigger_hint_runtime": "Die Aufgabe wird nach {hours} h aufsummierter Laufzeit fällig; der Zähler startet nach jedem Abschluss neu.",
"trigger_hint_state_change": "Die Aufgabe wird nach {count} Zustandswechsel(n) fällig.",
"trigger_hint_state_change_to": "Die Aufgabe wird nach {count} Wechsel(n) zu „{state}“ fällig.",
"trigger_hint_state_now": "Aktueller Zustand: {value}.",
"adopt_problem_part": "Nutzt Ersatzteil: {name}",
"label_filter": "Label",
"all_labels": "Alle Labels",
"settings_notify_scope": "Nur benachrichtigen für Ansicht",
"settings_notify_scope_all": "Alle Aufgaben",
"settings_notify_scope_hint": "Nur Aufgaben, die zu den Label-/Nutzer-Filtern der gewählten Ansicht passen, senden Erinnerungen. Status, Sortierung und Gruppierung der Ansicht werden hier ignoriert.",
"card_saved_view": "Gespeicherte Ansicht",
"card_saved_view_none": "Keine",
"card_saved_view_help": "Wendet die Status-, Nutzer- und Label-Filter der Ansicht zusätzlich zu den obigen Filtern an. Sortierung und Gruppierung der Ansicht sind Panel-Darstellung und werden auf der Karte nicht angewendet.",
"doc_part_none": "Keine Dokumente mit diesem Ersatzteil verknüpft."
}
@@ -476,6 +476,12 @@
"settings_export_success": "Export downloaded.",
"settings_saved": "Setting saved.",
"settings_include_history": "Include history",
"settings_export_selection": "Limit to selected objects (optional)",
"settings_docs_archive": "Documents archive (with files)",
"settings_docs_archive_hint": "The JSON/YAML/CSV exports carry settings only. This ZIP includes the uploaded file contents so a restore is complete.",
"settings_docs_export_btn": "Download documents ZIP",
"settings_docs_import_btn": "Restore documents ZIP",
"settings_docs_import_success": "Restored: {blobs} files, {docs} documents",
"sort_alphabetical": "Alphabetical",
"sort_due_soonest": "Due soonest",
"sort_task_count": "Task count",
@@ -706,5 +712,59 @@
"series_end_after_count": "After a number of times",
"series_end_until": "On a date",
"series_end_count_label": "Number of times",
"series_end_until_label": "End date"
"series_end_until_label": "End date",
"parts_section": "Parts & consumables",
"part_add": "Add part",
"part_name": "Name",
"part_vendor": "Manufacturer",
"part_storage_location": "Storage location",
"part_product_url": "Product URL",
"part_unit": "Unit",
"part_cost": "Unit price",
"part_stock": "Stock",
"part_reorder_threshold": "Reorder at",
"part_restock_quantity": "Restock quantity",
"part_auto_buy": "Auto-create buy task when low",
"part_restock": "Adjust stock",
"restock_quantity_label": "Quantity bought",
"consumes_parts_label": "Consumes parts",
"parts_load_failed": "Couldn't load this object's parts — the consumes-parts options are unavailable right now.",
"adopt_problem_button": "Adopt problem sensors",
"adopt_problem_title": "Adopt problem sensors",
"adopt_problem_hint": "Turn HA problem sensors (printer errors, filter warnings, low battery) into maintenance tasks that trigger while the problem is active and clear themselves when it resolves.",
"adopt_problem_none": "No problem sensors found that aren't already tracked.",
"adopt_problem_active": "active",
"adopt_problem_ok": "ok",
"adopt_problem_new_object": "(new)",
"adopt_problem_adopt": "Adopt selected",
"adopt_problem_done": "Adopted {tasks} problem sensor(s)",
"views_label": "Views",
"views_none": "— No view —",
"views_manage": "Save / manage views",
"views_dialog_title": "Saved views",
"views_dialog_hint": "Save the current filters as a named view everyone can reuse.",
"views_name_placeholder": "View name",
"views_save_current": "Save current filters",
"views_none_yet": "No saved views yet.",
"close": "Close",
"trigger_hint_now": "The sensor reads {value} right now.",
"trigger_hint_above": "The task triggers once it rises above {target}.",
"trigger_hint_below": "It triggers once it falls below {target}.",
"trigger_hint_counter_delta": "Counts from the current reading ({value}): due at {due} (+{target}), and the count restarts after each completion.",
"trigger_hint_counter_delta_edit": "Counts usage since the last completion: due after +{target}; the count restarts after each completion.",
"trigger_hint_counter_abs": "The task becomes due once the sensor reaches {target}.",
"trigger_hint_runtime": "The task becomes due after {hours} h of accumulated on-time; the counter restarts after each completion.",
"trigger_hint_state_change": "The task becomes due after {count} state change(s).",
"trigger_hint_state_change_to": "The task becomes due after {count} change(s) to “{state}”.",
"trigger_hint_state_now": "Current state: {value}.",
"adopt_problem_part": "Uses part: {name}",
"label_filter": "Label",
"all_labels": "All labels",
"settings_notify_scope": "Notify only for view",
"settings_notify_scope_all": "All tasks",
"settings_notify_scope_hint": "Only tasks matching the selected saved view's label/user filters send reminders. Status, sorting and grouping of the view are ignored here.",
"card_saved_view": "Saved view",
"card_saved_view_none": "None",
"card_saved_view_help": "Applies the view's status, user and label filters on top of the filters above. The view's sorting and grouping are panel display settings and are not applied on the card.",
"doc_part_none": "No documents linked to this part."
}
@@ -706,5 +706,65 @@
"series_end_after_count": "Tras un número de veces",
"series_end_until": "En una fecha",
"series_end_count_label": "Número de veces",
"series_end_until_label": "Fecha de fin"
"series_end_until_label": "Fecha de fin",
"parts_section": "Piezas y consumibles",
"part_add": "Añadir pieza",
"part_name": "Nombre",
"part_vendor": "Fabricante",
"part_storage_location": "Ubicación de almacenaje",
"part_product_url": "URL del producto",
"part_unit": "Unidad",
"part_cost": "Precio unitario",
"part_stock": "Existencias",
"part_reorder_threshold": "Umbral de reposición",
"part_restock_quantity": "Cantidad de reposición",
"part_auto_buy": "Crear tarea de compra si bajo",
"part_restock": "Ajustar existencias",
"restock_quantity_label": "Cantidad comprada",
"consumes_parts_label": "Consume piezas",
"parts_load_failed": "No se pudieron cargar las piezas de este objeto — las opciones de consumo de piezas no están disponibles ahora.",
"settings_export_selection": "Limitar a los objetos seleccionados (opcional)",
"settings_docs_archive": "Archivo de documentos (con archivos)",
"settings_docs_archive_hint": "Las exportaciones JSON/YAML/CSV solo contienen la configuración. Este ZIP incluye el contenido de los archivos subidos para una restauración completa.",
"settings_docs_export_btn": "Descargar ZIP de documentos",
"settings_docs_import_btn": "Restaurar ZIP de documentos",
"settings_docs_import_success": "Restaurado: {blobs} archivos, {docs} documentos",
"adopt_problem_button": "Adoptar sensores de problema",
"adopt_problem_title": "Adoptar sensores de problema",
"adopt_problem_hint": "Convierte los sensores de problema de HA (errores de impresora, avisos de filtro, batería baja) en tareas de mantenimiento que se activan mientras el problema está activo y se completan cuando se resuelve.",
"adopt_problem_none": "No se encontraron sensores de problema que no estén ya rastreados.",
"adopt_problem_active": "activo",
"adopt_problem_ok": "ok",
"adopt_problem_new_object": "(nuevo)",
"adopt_problem_adopt": "Adoptar seleccionados",
"adopt_problem_done": "{tasks} sensor(es) de problema adoptado(s)",
"views_label": "Vistas",
"views_none": "— Sin vista —",
"views_manage": "Guardar / gestionar vistas",
"views_dialog_title": "Vistas guardadas",
"views_dialog_hint": "Guarda los filtros actuales como una vista con nombre que todos pueden reutilizar.",
"views_name_placeholder": "Nombre de la vista",
"views_save_current": "Guardar filtros actuales",
"views_none_yet": "Aún no hay vistas guardadas.",
"close": "Cerrar",
"trigger_hint_now": "El sensor marca {value} ahora mismo.",
"trigger_hint_above": "La tarea se activa cuando supere {target}.",
"trigger_hint_below": "Se activa cuando baje de {target}.",
"trigger_hint_counter_delta": "Cuenta desde la lectura actual ({value}): vence en {due} (+{target}); el contador se reinicia tras cada finalización.",
"trigger_hint_counter_delta_edit": "Cuenta el uso desde la última finalización: vence tras +{target}; el contador se reinicia tras cada finalización.",
"trigger_hint_counter_abs": "La tarea vence cuando el sensor alcance {target}.",
"trigger_hint_runtime": "La tarea vence tras {hours} h de funcionamiento acumulado; el contador se reinicia tras cada finalización.",
"trigger_hint_state_change": "La tarea vence tras {count} cambio(s) de estado.",
"trigger_hint_state_change_to": "La tarea vence tras {count} cambio(s) a «{state}».",
"trigger_hint_state_now": "Estado actual: {value}.",
"adopt_problem_part": "Usa la pieza: {name}",
"label_filter": "Etiqueta",
"all_labels": "Todas las etiquetas",
"settings_notify_scope": "Notificar solo para la vista",
"settings_notify_scope_all": "Todas las tareas",
"settings_notify_scope_hint": "Solo las tareas que coinciden con los filtros de etiqueta/usuario de la vista seleccionada envían recordatorios. El estado, el orden y la agrupación de la vista se ignoran aquí.",
"card_saved_view": "Vista guardada",
"card_saved_view_none": "Ninguna",
"card_saved_view_help": "Aplica los filtros de estado, usuario y etiqueta de la vista además de los filtros anteriores. El orden y la agrupación de la vista son ajustes de visualización del panel y no se aplican en la tarjeta.",
"doc_part_none": "No hay documentos vinculados a esta pieza."
}
@@ -706,5 +706,65 @@
"series_end_after_count": "Tietyn kertamäärän jälkeen",
"series_end_until": "Päivämääränä",
"series_end_count_label": "Kertojen määrä",
"series_end_until_label": "Päättymispäivä"
"series_end_until_label": "Päättymispäivä",
"parts_section": "Osat ja tarvikkeet",
"part_add": "Lisää osa",
"part_name": "Nimi",
"part_vendor": "Valmistaja",
"part_storage_location": "Säilytyspaikka",
"part_product_url": "Tuotteen URL",
"part_unit": "Yksikkö",
"part_cost": "Yksikköhinta",
"part_stock": "Varasto",
"part_reorder_threshold": "Tilausraja",
"part_restock_quantity": "Täydennysmäärä",
"part_auto_buy": "Ostotehtävä kun vähissä",
"part_restock": "Muuta varastoa",
"restock_quantity_label": "Ostettu määrä",
"consumes_parts_label": "Kuluttaa osia",
"parts_load_failed": "Kohteen osia ei voitu ladata — osien kulutusvalinnat eivät ole juuri nyt käytettävissä.",
"settings_export_selection": "Rajaa valittuihin kohteisiin (valinnainen)",
"settings_docs_archive": "Asiakirja-arkisto (tiedostoineen)",
"settings_docs_archive_hint": "JSON/YAML/CSV-viennit sisältävät vain asetukset. Tämä ZIP sisältää ladattujen tiedostojen sisällön, joten palautus on täydellinen.",
"settings_docs_export_btn": "Lataa asiakirjojen ZIP",
"settings_docs_import_btn": "Palauta asiakirjojen ZIP",
"settings_docs_import_success": "Palautettu: {blobs} tiedostoa, {docs} asiakirjaa",
"adopt_problem_button": "Ota ongelma-anturit käyttöön",
"adopt_problem_title": "Ota ongelma-anturit käyttöön",
"adopt_problem_hint": "Muuttaa HA:n ongelma-anturit (tulostinvirheet, suodatinvaroitukset, heikko akku) huoltotehtäviksi, jotka laukeavat ongelman ollessa aktiivinen ja päättyvät itsestään, kun se ratkeaa.",
"adopt_problem_none": "Ei löytynyt ongelma-antureita, joita ei jo seurata.",
"adopt_problem_active": "aktiivinen",
"adopt_problem_ok": "ok",
"adopt_problem_new_object": "(uusi)",
"adopt_problem_adopt": "Ota valitut käyttöön",
"adopt_problem_done": "Otettiin käyttöön {tasks} ongelma-anturia",
"views_label": "Näkymät",
"views_none": "— Ei näkymää —",
"views_manage": "Tallenna / hallitse näkymiä",
"views_dialog_title": "Tallennetut näkymät",
"views_dialog_hint": "Tallenna nykyiset suodattimet nimettynä näkymänä, jota kaikki voivat käyttää.",
"views_name_placeholder": "Näkymän nimi",
"views_save_current": "Tallenna nykyiset suodattimet",
"views_none_yet": "Ei tallennettuja näkymiä vielä.",
"close": "Sulje",
"trigger_hint_now": "Anturi näyttää nyt {value}.",
"trigger_hint_above": "Tehtävä laukeaa, kun arvo ylittää {target}.",
"trigger_hint_below": "Se laukeaa, kun arvo alittaa {target}.",
"trigger_hint_counter_delta": "Laskee nykyisestä lukemasta ({value}): erääntyy kohdassa {due} (+{target}); laskuri alkaa alusta jokaisen suorituksen jälkeen.",
"trigger_hint_counter_delta_edit": "Laskee käytön viime suorituksesta: erääntyy +{target} jälkeen; laskuri alkaa alusta jokaisen suorituksen jälkeen.",
"trigger_hint_counter_abs": "Tehtävä erääntyy, kun anturi saavuttaa {target}.",
"trigger_hint_runtime": "Tehtävä erääntyy {hours} h kertyneen käyntiajan jälkeen; laskuri alkaa alusta jokaisen suorituksen jälkeen.",
"trigger_hint_state_change": "Tehtävä erääntyy {count} tilanmuutoksen jälkeen.",
"trigger_hint_state_change_to": "Tehtävä erääntyy {count} muutoksen jälkeen tilaan ”{state}”.",
"trigger_hint_state_now": "Nykyinen tila: {value}.",
"adopt_problem_part": "Käyttää varaosaa: {name}",
"label_filter": "Tunniste",
"all_labels": "Kaikki tunnisteet",
"settings_notify_scope": "Ilmoita vain näkymälle",
"settings_notify_scope_all": "Kaikki tehtävät",
"settings_notify_scope_hint": "Vain valitun näkymän tunniste-/käyttäjäsuodattimiin osuvat tehtävät lähettävät muistutuksia. Näkymän tila, lajittelu ja ryhmittely ohitetaan tässä.",
"card_saved_view": "Tallennettu näkymä",
"card_saved_view_none": "Ei mitään",
"card_saved_view_help": "Soveltaa näkymän tila-, käyttäjä- ja tunnistesuodattimet yllä olevien suodattimien lisäksi. Näkymän lajittelu ja ryhmittely ovat paneelin näyttöasetuksia eivätkä vaikuta korttiin.",
"doc_part_none": "Tähän osaan ei ole linkitetty asiakirjoja."
}
@@ -706,5 +706,65 @@
"series_end_after_count": "Après un nombre de fois",
"series_end_until": "À une date",
"series_end_count_label": "Nombre de fois",
"series_end_until_label": "Date de fin"
"series_end_until_label": "Date de fin",
"parts_section": "Pièces & consommables",
"part_add": "Ajouter une pièce",
"part_name": "Nom",
"part_vendor": "Fabricant",
"part_storage_location": "Lieu de stockage",
"part_product_url": "URL du produit",
"part_unit": "Unité",
"part_cost": "Prix unitaire",
"part_stock": "Stock",
"part_reorder_threshold": "Seuil de réappro",
"part_restock_quantity": "Quantité de réappro",
"part_auto_buy": "Tâche d'achat auto si bas",
"part_restock": "Ajuster le stock",
"restock_quantity_label": "Quantité achetée",
"consumes_parts_label": "Consomme des pièces",
"parts_load_failed": "Impossible de charger les pièces de cet objet — les options de consommation de pièces sont indisponibles pour le moment.",
"settings_export_selection": "Limiter aux objets sélectionnés (facultatif)",
"settings_docs_archive": "Archive de documents (avec fichiers)",
"settings_docs_archive_hint": "Les exports JSON/YAML/CSV ne contiennent que les réglages. Ce ZIP inclut le contenu des fichiers téléversés pour une restauration complète.",
"settings_docs_export_btn": "Télécharger le ZIP des documents",
"settings_docs_import_btn": "Restaurer le ZIP des documents",
"settings_docs_import_success": "Restauré : {blobs} fichiers, {docs} documents",
"adopt_problem_button": "Adopter les capteurs de problème",
"adopt_problem_title": "Adopter les capteurs de problème",
"adopt_problem_hint": "Transforme les capteurs de problème HA (erreurs d'imprimante, alertes de filtre, batterie faible) en tâches de maintenance qui se déclenchent tant que le problème est actif et se terminent lorsqu'il est résolu.",
"adopt_problem_none": "Aucun capteur de problème non encore suivi n'a été trouvé.",
"adopt_problem_active": "actif",
"adopt_problem_ok": "ok",
"adopt_problem_new_object": "(nouveau)",
"adopt_problem_adopt": "Adopter la sélection",
"adopt_problem_done": "{tasks} capteur(s) de problème adopté(s)",
"views_label": "Vues",
"views_none": "— Aucune vue —",
"views_manage": "Enregistrer / gérer les vues",
"views_dialog_title": "Vues enregistrées",
"views_dialog_hint": "Enregistrez les filtres actuels comme une vue nommée réutilisable par tous.",
"views_name_placeholder": "Nom de la vue",
"views_save_current": "Enregistrer les filtres actuels",
"views_none_yet": "Aucune vue enregistrée pour l'instant.",
"close": "Fermer",
"trigger_hint_now": "Le capteur indique {value} en ce moment.",
"trigger_hint_above": "La tâche se déclenche dès que la valeur dépasse {target}.",
"trigger_hint_below": "Elle se déclenche dès que la valeur passe sous {target}.",
"trigger_hint_counter_delta": "Compte à partir de la valeur actuelle ({value}) : échéance à {due} (+{target}) ; le compteur repart après chaque exécution.",
"trigger_hint_counter_delta_edit": "Compte l'usage depuis la dernière exécution : échéance après +{target} ; le compteur repart après chaque exécution.",
"trigger_hint_counter_abs": "La tâche devient due dès que le capteur atteint {target}.",
"trigger_hint_runtime": "La tâche devient due après {hours} h de fonctionnement cumulé ; le compteur repart après chaque exécution.",
"trigger_hint_state_change": "La tâche devient due après {count} changement(s) d'état.",
"trigger_hint_state_change_to": "La tâche devient due après {count} passage(s) à « {state} ».",
"trigger_hint_state_now": "État actuel : {value}.",
"adopt_problem_part": "Utilise la pièce : {name}",
"label_filter": "Étiquette",
"all_labels": "Toutes les étiquettes",
"settings_notify_scope": "Notifier uniquement pour la vue",
"settings_notify_scope_all": "Toutes les tâches",
"settings_notify_scope_hint": "Seules les tâches correspondant aux filtres étiquette/utilisateur de la vue choisie envoient des rappels. Le statut, le tri et le regroupement de la vue sont ignorés ici.",
"card_saved_view": "Vue enregistrée",
"card_saved_view_none": "Aucune",
"card_saved_view_help": "Applique les filtres statut, utilisateur et étiquette de la vue en plus des filtres ci-dessus. Le tri et le regroupement de la vue sont des réglages d'affichage du panneau et ne s'appliquent pas à la carte.",
"doc_part_none": "Aucun document lié à cette pièce."
}
@@ -706,5 +706,65 @@
"series_end_after_count": "कुछ बार के बाद",
"series_end_until": "एक तिथि पर",
"series_end_count_label": "कितनी बार",
"series_end_until_label": "समाप्ति तिथि"
"series_end_until_label": "समाप्ति तिथि",
"parts_section": "पुर्ज़े और उपभोग्य",
"part_add": "पुर्ज़ा जोड़ें",
"part_name": "नाम",
"part_vendor": "निर्माता",
"part_storage_location": "भंडारण स्थान",
"part_product_url": "उत्पाद URL",
"part_unit": "इकाई",
"part_cost": "इकाई मूल्य",
"part_stock": "स्टॉक",
"part_reorder_threshold": "पुनः ऑर्डर सीमा",
"part_restock_quantity": "पुनःपूर्ति मात्रा",
"part_auto_buy": "कम होने पर खरीद कार्य",
"part_restock": "स्टॉक समायोजित करें",
"restock_quantity_label": "खरीदी गई मात्रा",
"consumes_parts_label": "पुर्ज़े खपत",
"parts_load_failed": "इस ऑब्जेक्ट के पुर्ज़े लोड नहीं हो सके — पुर्ज़ा-खपत विकल्प अभी उपलब्ध नहीं हैं।",
"settings_export_selection": "चयनित ऑब्जेक्ट तक सीमित करें (वैकल्पिक)",
"settings_docs_archive": "दस्तावेज़ संग्रह (फ़ाइलों सहित)",
"settings_docs_archive_hint": "JSON/YAML/CSV निर्यात केवल सेटिंग्स रखते हैं। इस ZIP में अपलोड की गई फ़ाइल सामग्री शामिल है ताकि पुनर्स्थापना पूर्ण हो।",
"settings_docs_export_btn": "दस्तावेज़ ZIP डाउनलोड करें",
"settings_docs_import_btn": "दस्तावेज़ ZIP पुनर्स्थापित करें",
"settings_docs_import_success": "पुनर्स्थापित: {blobs} फ़ाइलें, {docs} दस्तावेज़",
"adopt_problem_button": "समस्या सेंसर अपनाएँ",
"adopt_problem_title": "समस्या सेंसर अपनाएँ",
"adopt_problem_hint": "HA समस्या सेंसर (प्रिंटर त्रुटियाँ, फ़िल्टर चेतावनियाँ, कम बैटरी) को रखरखाव कार्यों में बदलें जो समस्या सक्रिय रहने तक ट्रिगर होते हैं और हल होने पर स्वयं पूरे हो जाते हैं।",
"adopt_problem_none": "ऐसा कोई समस्या सेंसर नहीं मिला जो पहले से ट्रैक न हो।",
"adopt_problem_active": "सक्रिय",
"adopt_problem_ok": "ठीक",
"adopt_problem_new_object": "(नया)",
"adopt_problem_adopt": "चयनित अपनाएँ",
"adopt_problem_done": "{tasks} समस्या सेंसर अपनाए गए",
"views_label": "व्यू",
"views_none": "— कोई व्यू नहीं —",
"views_manage": "व्यू सहेजें / प्रबंधित करें",
"views_dialog_title": "सहेजे गए व्यू",
"views_dialog_hint": "वर्तमान फ़िल्टर को एक नामित व्यू के रूप में सहेजें जिसे सभी उपयोग कर सकें।",
"views_name_placeholder": "व्यू का नाम",
"views_save_current": "वर्तमान फ़िल्टर सहेजें",
"views_none_yet": "अभी तक कोई सहेजा गया व्यू नहीं।",
"close": "बंद करें",
"trigger_hint_now": "सेंसर अभी {value} दिखा रहा है।",
"trigger_hint_above": "मान {target} से ऊपर जाते ही कार्य ट्रिगर होगा।",
"trigger_hint_below": "मान {target} से नीचे आते ही यह ट्रिगर होगा।",
"trigger_hint_counter_delta": "वर्तमान रीडिंग ({value}) से गिनती: {due} पर देय (+{target}); हर पूर्णता के बाद गिनती फिर से शुरू होती है।",
"trigger_hint_counter_delta_edit": "पिछली पूर्णता से उपयोग गिनता है: +{target} के बाद देय; हर पूर्णता के बाद गिनती फिर से शुरू होती है।",
"trigger_hint_counter_abs": "सेंसर {target} तक पहुँचते ही कार्य देय होगा।",
"trigger_hint_runtime": "{hours} घंटे की संचित चालू-अवधि के बाद कार्य देय होगा; हर पूर्णता के बाद गिनती फिर से शुरू होती है।",
"trigger_hint_state_change": "{count} स्थिति परिवर्तन(ों) के बाद कार्य देय होगा।",
"trigger_hint_state_change_to": "“{state}” में {count} परिवर्तन(ों) के बाद कार्य देय होगा।",
"trigger_hint_state_now": "वर्तमान स्थिति: {value}।",
"adopt_problem_part": "पुर्ज़ा उपयोग करता है: {name}",
"label_filter": "लेबल",
"all_labels": "सभी लेबल",
"settings_notify_scope": "केवल इस व्यू के लिए सूचित करें",
"settings_notify_scope_all": "सभी कार्य",
"settings_notify_scope_hint": "केवल वे कार्य अनुस्मारक भेजते हैं जो चुने गए व्यू के लेबल/उपयोगकर्ता फ़िल्टर से मेल खाते हैं। व्यू की स्थिति, क्रम और समूहन यहाँ अनदेखे रहते हैं।",
"card_saved_view": "सहेजा गया व्यू",
"card_saved_view_none": "कोई नहीं",
"card_saved_view_help": "ऊपर दिए फ़िल्टरों के अतिरिक्त व्यू के स्थिति, उपयोगकर्ता और लेबल फ़िल्टर लागू करता है। व्यू का क्रम और समूहन पैनल की प्रदर्शन सेटिंग हैं और कार्ड पर लागू नहीं होते।",
"doc_part_none": "इस पुर्ज़े से कोई दस्तावेज़ जुड़ा नहीं है।"
}
@@ -706,5 +706,65 @@
"series_end_after_count": "Dopo un numero di volte",
"series_end_until": "In una data",
"series_end_count_label": "Numero di volte",
"series_end_until_label": "Data di fine"
"series_end_until_label": "Data di fine",
"parts_section": "Ricambi e consumabili",
"part_add": "Aggiungi ricambio",
"part_name": "Nome",
"part_vendor": "Produttore",
"part_storage_location": "Luogo di conservazione",
"part_product_url": "URL del prodotto",
"part_unit": "Unità",
"part_cost": "Prezzo unitario",
"part_stock": "Scorta",
"part_reorder_threshold": "Soglia riordino",
"part_restock_quantity": "Quantità di riordino",
"part_auto_buy": "Task d'acquisto se scarso",
"part_restock": "Correggi scorta",
"restock_quantity_label": "Quantità acquistata",
"consumes_parts_label": "Consuma ricambi",
"parts_load_failed": "Impossibile caricare i ricambi di questo oggetto — le opzioni di consumo ricambi non sono al momento disponibili.",
"settings_export_selection": "Limita agli oggetti selezionati (facoltativo)",
"settings_docs_archive": "Archivio documenti (con file)",
"settings_docs_archive_hint": "Gli export JSON/YAML/CSV contengono solo le impostazioni. Questo ZIP include il contenuto dei file caricati per un ripristino completo.",
"settings_docs_export_btn": "Scarica ZIP dei documenti",
"settings_docs_import_btn": "Ripristina ZIP dei documenti",
"settings_docs_import_success": "Ripristinati: {blobs} file, {docs} documenti",
"adopt_problem_button": "Adotta sensori di problema",
"adopt_problem_title": "Adotta sensori di problema",
"adopt_problem_hint": "Trasforma i sensori di problema di HA (errori stampante, avvisi filtro, batteria scarica) in attività di manutenzione che si attivano finché il problema è attivo e si completano quando si risolve.",
"adopt_problem_none": "Nessun sensore di problema non ancora monitorato trovato.",
"adopt_problem_active": "attivo",
"adopt_problem_ok": "ok",
"adopt_problem_new_object": "(nuovo)",
"adopt_problem_adopt": "Adotta selezionati",
"adopt_problem_done": "{tasks} sensore/i di problema adottato/i",
"views_label": "Viste",
"views_none": "— Nessuna vista —",
"views_manage": "Salva / gestisci viste",
"views_dialog_title": "Viste salvate",
"views_dialog_hint": "Salva i filtri attuali come vista con nome riutilizzabile da tutti.",
"views_name_placeholder": "Nome della vista",
"views_save_current": "Salva filtri attuali",
"views_none_yet": "Nessuna vista salvata.",
"close": "Chiudi",
"trigger_hint_now": "Il sensore indica {value} in questo momento.",
"trigger_hint_above": "L'attività scatta quando supera {target}.",
"trigger_hint_below": "Scatta quando scende sotto {target}.",
"trigger_hint_counter_delta": "Conta dalla lettura attuale ({value}): scade a {due} (+{target}); il conteggio riparte dopo ogni completamento.",
"trigger_hint_counter_delta_edit": "Conta l'uso dall'ultimo completamento: scade dopo +{target}; il conteggio riparte dopo ogni completamento.",
"trigger_hint_counter_abs": "L'attività scade quando il sensore raggiunge {target}.",
"trigger_hint_runtime": "L'attività scade dopo {hours} h di funzionamento accumulato; il conteggio riparte dopo ogni completamento.",
"trigger_hint_state_change": "L'attività scade dopo {count} cambi(o) di stato.",
"trigger_hint_state_change_to": "L'attività scade dopo {count} passaggi(o) a «{state}».",
"trigger_hint_state_now": "Stato attuale: {value}.",
"adopt_problem_part": "Usa il ricambio: {name}",
"label_filter": "Etichetta",
"all_labels": "Tutte le etichette",
"settings_notify_scope": "Notifica solo per la vista",
"settings_notify_scope_all": "Tutte le attività",
"settings_notify_scope_hint": "Solo le attività che corrispondono ai filtri etichetta/utente della vista scelta inviano promemoria. Stato, ordinamento e raggruppamento della vista qui vengono ignorati.",
"card_saved_view": "Vista salvata",
"card_saved_view_none": "Nessuna",
"card_saved_view_help": "Applica i filtri stato, utente ed etichetta della vista in aggiunta ai filtri sopra. Ordinamento e raggruppamento della vista sono impostazioni di visualizzazione del pannello e non si applicano alla scheda.",
"doc_part_none": "Nessun documento collegato a questo ricambio."
}
@@ -706,5 +706,65 @@
"series_end_after_count": "回数の後",
"series_end_until": "日付で",
"series_end_count_label": "回数",
"series_end_until_label": "終了日"
"series_end_until_label": "終了日",
"parts_section": "部品・消耗品",
"part_add": "部品を追加",
"part_name": "名前",
"part_vendor": "メーカー",
"part_storage_location": "保管場所",
"part_product_url": "製品URL",
"part_unit": "単位",
"part_cost": "単価",
"part_stock": "在庫",
"part_reorder_threshold": "再注文しきい値",
"part_restock_quantity": "補充数量",
"part_auto_buy": "在庫少で購入タスク",
"part_restock": "在庫を調整",
"restock_quantity_label": "購入数量",
"consumes_parts_label": "部品を消費",
"parts_load_failed": "このオブジェクトの部品を読み込めませんでした — 部品消費オプションは現在利用できません。",
"settings_export_selection": "選択したオブジェクトに限定(任意)",
"settings_docs_archive": "ドキュメントアーカイブ(ファイル付き)",
"settings_docs_archive_hint": "JSON/YAML/CSV エクスポートには設定のみが含まれます。この ZIP にはアップロードしたファイルの内容が含まれ、復元が完全になります。",
"settings_docs_export_btn": "ドキュメント ZIP をダウンロード",
"settings_docs_import_btn": "ドキュメント ZIP を復元",
"settings_docs_import_success": "復元: {blobs} 個のファイル、{docs} 件のドキュメント",
"adopt_problem_button": "問題センサーを取り込む",
"adopt_problem_title": "問題センサーを取り込む",
"adopt_problem_hint": "HA の問題センサー(プリンターエラー、フィルター警告、バッテリー低下)を、問題が続く間トリガーされ、解消すると自動的に完了するメンテナンスタスクに変換します。",
"adopt_problem_none": "まだ追跡されていない問題センサーは見つかりませんでした。",
"adopt_problem_active": "発生中",
"adopt_problem_ok": "正常",
"adopt_problem_new_object": "(新規)",
"adopt_problem_adopt": "選択を取り込む",
"adopt_problem_done": "{tasks} 件の問題センサーを取り込みました",
"views_label": "ビュー",
"views_none": "— ビューなし —",
"views_manage": "ビューを保存/管理",
"views_dialog_title": "保存済みビュー",
"views_dialog_hint": "現在のフィルターを、全員が再利用できる名前付きビューとして保存します。",
"views_name_placeholder": "ビュー名",
"views_save_current": "現在のフィルターを保存",
"views_none_yet": "保存済みのビューはまだありません。",
"close": "閉じる",
"trigger_hint_now": "センサーは現在 {value} を示しています。",
"trigger_hint_above": "値が {target} を上回るとタスクが発動します。",
"trigger_hint_below": "値が {target} を下回ると発動します。",
"trigger_hint_counter_delta": "現在の読み値({value})から数えます: {due}+{target})で期限になり、完了のたびにカウントはやり直しになります。",
"trigger_hint_counter_delta_edit": "前回の完了からの使用量を数えます: +{target} で期限になり、完了のたびにカウントはやり直しになります。",
"trigger_hint_counter_abs": "センサーが {target} に達するとタスクが期限になります。",
"trigger_hint_runtime": "累積稼働 {hours} 時間でタスクが期限になります。完了のたびにカウントはやり直しになります。",
"trigger_hint_state_change": "{count} 回の状態変化でタスクが期限になります。",
"trigger_hint_state_change_to": "「{state}」への {count} 回の変化でタスクが期限になります。",
"trigger_hint_state_now": "現在の状態: {value}。",
"adopt_problem_part": "使用する部品: {name}",
"label_filter": "ラベル",
"all_labels": "すべてのラベル",
"settings_notify_scope": "このビューのみ通知",
"settings_notify_scope_all": "すべてのタスク",
"settings_notify_scope_hint": "選択した保存済みビューのラベル/ユーザーフィルターに一致するタスクのみがリマインダーを送信します。ビューのステータス・並び順・グループ化はここでは無視されます。",
"card_saved_view": "保存済みビュー",
"card_saved_view_none": "なし",
"card_saved_view_help": "上記のフィルターに加えて、ビューのステータス・ユーザー・ラベルのフィルターを適用します。ビューの並び順とグループ化はパネルの表示設定であり、カードには適用されません。",
"doc_part_none": "この部品にリンクされた文書はありません。"
}
@@ -706,5 +706,65 @@
"series_end_after_count": "Etter et antall ganger",
"series_end_until": "På en dato",
"series_end_count_label": "Antall ganger",
"series_end_until_label": "Sluttdato"
"series_end_until_label": "Sluttdato",
"parts_section": "Deler & forbruksvarer",
"part_add": "Legg til del",
"part_name": "Navn",
"part_vendor": "Produsent",
"part_storage_location": "Lagringssted",
"part_product_url": "Produkt-URL",
"part_unit": "Enhet",
"part_cost": "Stykkpris",
"part_stock": "Lager",
"part_reorder_threshold": "Bestillingsgrense",
"part_restock_quantity": "Påfyllingsmengde",
"part_auto_buy": "Kjøpsoppgave ved lavt lager",
"part_restock": "Juster lager",
"restock_quantity_label": "Kjøpt mengde",
"consumes_parts_label": "Forbruker deler",
"parts_load_failed": "Kunne ikke laste objektets deler — forbruksvalgene er ikke tilgjengelige nå.",
"settings_export_selection": "Begrens til valgte objekter (valgfritt)",
"settings_docs_archive": "Dokumentarkiv (med filer)",
"settings_docs_archive_hint": "JSON/YAML/CSV-eksportene inneholder bare innstillinger. Denne ZIP-en inkluderer innholdet i de opplastede filene, slik at gjenoppretting blir fullstendig.",
"settings_docs_export_btn": "Last ned dokument-ZIP",
"settings_docs_import_btn": "Gjenopprett dokument-ZIP",
"settings_docs_import_success": "Gjenopprettet: {blobs} filer, {docs} dokumenter",
"adopt_problem_button": "Ta i bruk problemsensorer",
"adopt_problem_title": "Ta i bruk problemsensorer",
"adopt_problem_hint": "Gjør HA-problemsensorer (skriverfeil, filteradvarsler, lavt batteri) om til vedlikeholdsoppgaver som utløses mens problemet er aktivt og fullføres selv når det løses.",
"adopt_problem_none": "Fant ingen problemsensorer som ikke allerede spores.",
"adopt_problem_active": "aktiv",
"adopt_problem_ok": "ok",
"adopt_problem_new_object": "(ny)",
"adopt_problem_adopt": "Ta i bruk valgte",
"adopt_problem_done": "Tok i bruk {tasks} problemsensor(er)",
"views_label": "Visninger",
"views_none": "— Ingen visning —",
"views_manage": "Lagre / administrer visninger",
"views_dialog_title": "Lagrede visninger",
"views_dialog_hint": "Lagre gjeldende filtre som en navngitt visning alle kan bruke.",
"views_name_placeholder": "Visningsnavn",
"views_save_current": "Lagre gjeldende filtre",
"views_none_yet": "Ingen lagrede visninger ennå.",
"close": "Lukk",
"trigger_hint_now": "Sensoren viser {value} akkurat nå.",
"trigger_hint_above": "Oppgaven utløses når verdien stiger over {target}.",
"trigger_hint_below": "Den utløses når verdien faller under {target}.",
"trigger_hint_counter_delta": "Teller fra gjeldende avlesning ({value}): forfaller ved {due} (+{target}); telleren starter på nytt etter hver fullføring.",
"trigger_hint_counter_delta_edit": "Teller forbruket siden forrige fullføring: forfaller etter +{target}; telleren starter på nytt etter hver fullføring.",
"trigger_hint_counter_abs": "Oppgaven forfaller når sensoren når {target}.",
"trigger_hint_runtime": "Oppgaven forfaller etter {hours} t akkumulert driftstid; telleren starter på nytt etter hver fullføring.",
"trigger_hint_state_change": "Oppgaven forfaller etter {count} tilstandsendring(er).",
"trigger_hint_state_change_to": "Oppgaven forfaller etter {count} endring(er) til “{state}”.",
"trigger_hint_state_now": "Gjeldende tilstand: {value}.",
"adopt_problem_part": "Bruker del: {name}",
"label_filter": "Etikett",
"all_labels": "Alle etiketter",
"settings_notify_scope": "Varsle kun for visning",
"settings_notify_scope_all": "Alle oppgaver",
"settings_notify_scope_hint": "Bare oppgaver som matcher etikett-/brukerfiltrene til valgt visning sender påminnelser. Visningens status, sortering og gruppering ignoreres her.",
"card_saved_view": "Lagret visning",
"card_saved_view_none": "Ingen",
"card_saved_view_help": "Bruker visningens status-, bruker- og etikettfiltre i tillegg til filtrene over. Visningens sortering og gruppering er panelvisningsinnstillinger og brukes ikke på kortet.",
"doc_part_none": "Ingen dokumenter knyttet til denne delen."
}
@@ -706,5 +706,65 @@
"series_end_after_count": "Na een aantal keer",
"series_end_until": "Op een datum",
"series_end_count_label": "Aantal keer",
"series_end_until_label": "Einddatum"
"series_end_until_label": "Einddatum",
"parts_section": "Onderdelen & verbruiksartikelen",
"part_add": "Onderdeel toevoegen",
"part_name": "Naam",
"part_vendor": "Fabrikant",
"part_storage_location": "Opslaglocatie",
"part_product_url": "Product-URL",
"part_unit": "Eenheid",
"part_cost": "Stukprijs",
"part_stock": "Voorraad",
"part_reorder_threshold": "Bestelgrens",
"part_restock_quantity": "Aanvulhoeveelheid",
"part_auto_buy": "Kooptaak bij lage voorraad",
"part_restock": "Voorraad aanpassen",
"restock_quantity_label": "Gekochte hoeveelheid",
"consumes_parts_label": "Verbruikt onderdelen",
"parts_load_failed": "Kon de onderdelen van dit object niet laden — de verbruiksopties zijn nu niet beschikbaar.",
"settings_export_selection": "Beperken tot geselecteerde objecten (optioneel)",
"settings_docs_archive": "Documentenarchief (met bestanden)",
"settings_docs_archive_hint": "De JSON/YAML/CSV-exports bevatten alleen instellingen. Deze ZIP bevat de geüploade bestandsinhoud voor een volledig herstel.",
"settings_docs_export_btn": "Documenten-ZIP downloaden",
"settings_docs_import_btn": "Documenten-ZIP herstellen",
"settings_docs_import_success": "Hersteld: {blobs} bestanden, {docs} documenten",
"adopt_problem_button": "Probleemsensoren overnemen",
"adopt_problem_title": "Probleemsensoren overnemen",
"adopt_problem_hint": "Zet HA-probleemsensoren (printerfouten, filterwaarschuwingen, lege batterij) om in onderhoudstaken die triggeren zolang het probleem actief is en zichzelf afronden wanneer het is opgelost.",
"adopt_problem_none": "Geen probleemsensoren gevonden die nog niet worden gevolgd.",
"adopt_problem_active": "actief",
"adopt_problem_ok": "ok",
"adopt_problem_new_object": "(nieuw)",
"adopt_problem_adopt": "Selectie overnemen",
"adopt_problem_done": "{tasks} probleemsensor(en) overgenomen",
"views_label": "Weergaven",
"views_none": "— Geen weergave —",
"views_manage": "Weergaven opslaan/beheren",
"views_dialog_title": "Opgeslagen weergaven",
"views_dialog_hint": "Sla de huidige filters op als een benoemde weergave die iedereen kan gebruiken.",
"views_name_placeholder": "Naam weergave",
"views_save_current": "Huidige filters opslaan",
"views_none_yet": "Nog geen opgeslagen weergaven.",
"close": "Sluiten",
"trigger_hint_now": "De sensor staat nu op {value}.",
"trigger_hint_above": "De taak wordt geactiveerd zodra de waarde boven {target} komt.",
"trigger_hint_below": "Hij wordt geactiveerd zodra de waarde onder {target} zakt.",
"trigger_hint_counter_delta": "Telt vanaf de huidige stand ({value}): vervalt bij {due} (+{target}); de teller herstart na elke afronding.",
"trigger_hint_counter_delta_edit": "Telt het gebruik sinds de laatste afronding: vervalt na +{target}; de teller herstart na elke afronding.",
"trigger_hint_counter_abs": "De taak vervalt zodra de sensor {target} bereikt.",
"trigger_hint_runtime": "De taak vervalt na {hours} u opgetelde draaitijd; de teller herstart na elke afronding.",
"trigger_hint_state_change": "De taak vervalt na {count} statuswijziging(en).",
"trigger_hint_state_change_to": "De taak vervalt na {count} wijziging(en) naar “{state}”.",
"trigger_hint_state_now": "Huidige status: {value}.",
"adopt_problem_part": "Gebruikt onderdeel: {name}",
"label_filter": "Label",
"all_labels": "Alle labels",
"settings_notify_scope": "Alleen melden voor weergave",
"settings_notify_scope_all": "Alle taken",
"settings_notify_scope_hint": "Alleen taken die overeenkomen met de label-/gebruikersfilters van de gekozen weergave sturen herinneringen. Status, sortering en groepering van de weergave worden hier genegeerd.",
"card_saved_view": "Opgeslagen weergave",
"card_saved_view_none": "Geen",
"card_saved_view_help": "Past de status-, gebruikers- en labelfilters van de weergave toe bovenop de bovenstaande filters. Sortering en groepering van de weergave zijn paneelweergave-instellingen en gelden niet voor de kaart.",
"doc_part_none": "Geen documenten gekoppeld aan dit onderdeel."
}
@@ -706,5 +706,65 @@
"series_end_after_count": "Po liczbie razy",
"series_end_until": "W dniu",
"series_end_count_label": "Liczba razy",
"series_end_until_label": "Data końcowa"
"series_end_until_label": "Data końcowa",
"parts_section": "Części i materiały",
"part_add": "Dodaj część",
"part_name": "Nazwa",
"part_vendor": "Producent",
"part_storage_location": "Miejsce przechowywania",
"part_product_url": "Adres URL produktu",
"part_unit": "Jednostka",
"part_cost": "Cena jednostkowa",
"part_stock": "Stan",
"part_reorder_threshold": "Próg zamówienia",
"part_restock_quantity": "Ilość uzupełnienia",
"part_auto_buy": "Zadanie zakupu przy niskim stanie",
"part_restock": "Koryguj stan",
"restock_quantity_label": "Kupiona ilość",
"consumes_parts_label": "Zużywa części",
"parts_load_failed": "Nie udało się wczytać części tego obiektu — opcje zużycia części są teraz niedostępne.",
"settings_export_selection": "Ogranicz do wybranych obiektów (opcjonalnie)",
"settings_docs_archive": "Archiwum dokumentów (z plikami)",
"settings_docs_archive_hint": "Eksporty JSON/YAML/CSV zawierają tylko ustawienia. To ZIP zawiera treść przesłanych plików, aby przywracanie było kompletne.",
"settings_docs_export_btn": "Pobierz ZIP dokumentów",
"settings_docs_import_btn": "Przywróć ZIP dokumentów",
"settings_docs_import_success": "Przywrócono: {blobs} plików, {docs} dokumentów",
"adopt_problem_button": "Przejmij czujniki problemów",
"adopt_problem_title": "Przejmij czujniki problemów",
"adopt_problem_hint": "Zamienia czujniki problemów HA (błędy drukarki, ostrzeżenia filtra, niski poziom baterii) w zadania konserwacji, które uruchamiają się, gdy problem jest aktywny, i kończą się po jego rozwiązaniu.",
"adopt_problem_none": "Nie znaleziono czujników problemów, które nie są już śledzone.",
"adopt_problem_active": "aktywny",
"adopt_problem_ok": "ok",
"adopt_problem_new_object": "(nowy)",
"adopt_problem_adopt": "Przejmij zaznaczone",
"adopt_problem_done": "Przejęto {tasks} czujnik(ów) problemów",
"views_label": "Widoki",
"views_none": "— Brak widoku —",
"views_manage": "Zapisz / zarządzaj widokami",
"views_dialog_title": "Zapisane widoki",
"views_dialog_hint": "Zapisz bieżące filtry jako nazwany widok, którego każdy może użyć.",
"views_name_placeholder": "Nazwa widoku",
"views_save_current": "Zapisz bieżące filtry",
"views_none_yet": "Brak zapisanych widoków.",
"close": "Zamknij",
"trigger_hint_now": "Czujnik wskazuje teraz {value}.",
"trigger_hint_above": "Zadanie uruchomi się, gdy wartość przekroczy {target}.",
"trigger_hint_below": "Uruchomi się, gdy wartość spadnie poniżej {target}.",
"trigger_hint_counter_delta": "Liczy od bieżącego odczytu ({value}): termin przy {due} (+{target}); licznik startuje od nowa po każdym ukończeniu.",
"trigger_hint_counter_delta_edit": "Liczy zużycie od ostatniego ukończenia: termin po +{target}; licznik startuje od nowa po każdym ukończeniu.",
"trigger_hint_counter_abs": "Zadanie staje się wymagalne, gdy czujnik osiągnie {target}.",
"trigger_hint_runtime": "Zadanie staje się wymagalne po {hours} h łącznej pracy; licznik startuje od nowa po każdym ukończeniu.",
"trigger_hint_state_change": "Zadanie staje się wymagalne po {count} zmianie/zmianach stanu.",
"trigger_hint_state_change_to": "Zadanie staje się wymagalne po {count} zmianie/zmianach na „{state}”.",
"trigger_hint_state_now": "Bieżący stan: {value}.",
"adopt_problem_part": "Używa części: {name}",
"label_filter": "Etykieta",
"all_labels": "Wszystkie etykiety",
"settings_notify_scope": "Powiadamiaj tylko dla widoku",
"settings_notify_scope_all": "Wszystkie zadania",
"settings_notify_scope_hint": "Przypomnienia wysyłają tylko zadania pasujące do filtrów etykiety/użytkownika wybranego widoku. Status, sortowanie i grupowanie widoku są tu ignorowane.",
"card_saved_view": "Zapisany widok",
"card_saved_view_none": "Brak",
"card_saved_view_help": "Stosuje filtry statusu, użytkownika i etykiety widoku oprócz powyższych filtrów. Sortowanie i grupowanie widoku to ustawienia wyświetlania panelu i nie są stosowane na karcie.",
"doc_part_none": "Brak dokumentów powiązanych z tą częścią."
}
@@ -706,5 +706,65 @@
"series_end_after_count": "Após um número de vezes",
"series_end_until": "Numa data",
"series_end_count_label": "Número de vezes",
"series_end_until_label": "Data final"
"series_end_until_label": "Data final",
"parts_section": "Peças e consumíveis",
"part_add": "Adicionar peça",
"part_name": "Nome",
"part_vendor": "Fabricante",
"part_storage_location": "Local de armazenamento",
"part_product_url": "URL do produto",
"part_unit": "Unidade",
"part_cost": "Preço unitário",
"part_stock": "Estoque",
"part_reorder_threshold": "Limite de reposição",
"part_restock_quantity": "Quantidade de reposição",
"part_auto_buy": "Tarefa de compra se baixo",
"part_restock": "Ajustar estoque",
"restock_quantity_label": "Quantidade comprada",
"consumes_parts_label": "Consome peças",
"parts_load_failed": "Não foi possível carregar as peças deste objeto — as opções de consumo de peças estão indisponíveis.",
"settings_export_selection": "Limitar aos objetos selecionados (opcional)",
"settings_docs_archive": "Arquivo de documentos (com ficheiros)",
"settings_docs_archive_hint": "As exportações JSON/YAML/CSV contêm apenas as definições. Este ZIP inclui o conteúdo dos ficheiros carregados para uma restauração completa.",
"settings_docs_export_btn": "Descarregar ZIP de documentos",
"settings_docs_import_btn": "Restaurar ZIP de documentos",
"settings_docs_import_success": "Restaurado: {blobs} ficheiros, {docs} documentos",
"adopt_problem_button": "Adotar sensores de problema",
"adopt_problem_title": "Adotar sensores de problema",
"adopt_problem_hint": "Transforma os sensores de problema do HA (erros de impressora, avisos de filtro, bateria fraca) em tarefas de manutenção que são acionadas enquanto o problema está ativo e se concluem quando é resolvido.",
"adopt_problem_none": "Não foram encontrados sensores de problema que ainda não estejam a ser acompanhados.",
"adopt_problem_active": "ativo",
"adopt_problem_ok": "ok",
"adopt_problem_new_object": "(novo)",
"adopt_problem_adopt": "Adotar selecionados",
"adopt_problem_done": "{tasks} sensor(es) de problema adotado(s)",
"views_label": "Vistas",
"views_none": "— Sem vista —",
"views_manage": "Guardar / gerir vistas",
"views_dialog_title": "Vistas guardadas",
"views_dialog_hint": "Guarde os filtros atuais como uma vista nomeada que todos podem reutilizar.",
"views_name_placeholder": "Nome da vista",
"views_save_current": "Guardar filtros atuais",
"views_none_yet": "Ainda não há vistas guardadas.",
"close": "Fechar",
"trigger_hint_now": "O sensor marca {value} neste momento.",
"trigger_hint_above": "A tarefa dispara quando ultrapassar {target}.",
"trigger_hint_below": "Dispara quando descer abaixo de {target}.",
"trigger_hint_counter_delta": "Conta a partir da leitura atual ({value}): vence em {due} (+{target}); a contagem recomeça após cada conclusão.",
"trigger_hint_counter_delta_edit": "Conta o uso desde a última conclusão: vence após +{target}; a contagem recomeça após cada conclusão.",
"trigger_hint_counter_abs": "A tarefa vence quando o sensor atingir {target}.",
"trigger_hint_runtime": "A tarefa vence após {hours} h de funcionamento acumulado; a contagem recomeça após cada conclusão.",
"trigger_hint_state_change": "A tarefa vence após {count} mudança(s) de estado.",
"trigger_hint_state_change_to": "A tarefa vence após {count} mudança(s) para «{state}».",
"trigger_hint_state_now": "Estado atual: {value}.",
"adopt_problem_part": "Usa a peça: {name}",
"label_filter": "Etiqueta",
"all_labels": "Todas as etiquetas",
"settings_notify_scope": "Notificar apenas para a vista",
"settings_notify_scope_all": "Todas as tarefas",
"settings_notify_scope_hint": "Apenas as tarefas que correspondem aos filtros de etiqueta/utilizador da vista escolhida enviam lembretes. O estado, a ordenação e o agrupamento da vista são ignorados aqui.",
"card_saved_view": "Vista guardada",
"card_saved_view_none": "Nenhuma",
"card_saved_view_help": "Aplica os filtros de estado, utilizador e etiqueta da vista além dos filtros acima. A ordenação e o agrupamento da vista são definições de apresentação do painel e não se aplicam ao cartão.",
"doc_part_none": "Nenhum documento associado a esta peça."
}
@@ -706,5 +706,65 @@
"series_end_after_count": "После числа раз",
"series_end_until": "В дату",
"series_end_count_label": "Число раз",
"series_end_until_label": "Дата окончания"
"series_end_until_label": "Дата окончания",
"parts_section": "Детали и расходники",
"part_add": "Добавить деталь",
"part_name": "Название",
"part_vendor": "Производитель",
"part_storage_location": "Место хранения",
"part_product_url": "Ссылка на товар",
"part_unit": "Ед. изм.",
"part_cost": "Цена за шт.",
"part_stock": "Запас",
"part_reorder_threshold": "Порог дозаказа",
"part_restock_quantity": "Количество пополнения",
"part_auto_buy": "Задача покупки при малом запасе",
"part_restock": "Изменить запас",
"restock_quantity_label": "Куплено, шт.",
"consumes_parts_label": "Расходует детали",
"parts_load_failed": "Не удалось загрузить запчасти этого объекта — параметры расхода запчастей сейчас недоступны.",
"settings_export_selection": "Ограничить выбранными объектами (необязательно)",
"settings_docs_archive": "Архив документов (с файлами)",
"settings_docs_archive_hint": "Экспорты JSON/YAML/CSV содержат только настройки. Этот ZIP включает содержимое загруженных файлов, чтобы восстановление было полным.",
"settings_docs_export_btn": "Скачать ZIP документов",
"settings_docs_import_btn": "Восстановить ZIP документов",
"settings_docs_import_success": "Восстановлено: {blobs} файлов, {docs} документов",
"adopt_problem_button": "Принять сенсоры проблем",
"adopt_problem_title": "Принять сенсоры проблем",
"adopt_problem_hint": "Превращает сенсоры проблем HA (ошибки принтера, предупреждения фильтра, низкий заряд) в задачи обслуживания, которые срабатывают, пока проблема активна, и завершаются сами при её устранении.",
"adopt_problem_none": "Не найдено сенсоров проблем, которые ещё не отслеживаются.",
"adopt_problem_active": "активна",
"adopt_problem_ok": "норма",
"adopt_problem_new_object": "(новый)",
"adopt_problem_adopt": "Принять выбранные",
"adopt_problem_done": "Принято сенсоров проблем: {tasks}",
"views_label": "Представления",
"views_none": "— Без представления —",
"views_manage": "Сохранить / управлять представлениями",
"views_dialog_title": "Сохранённые представления",
"views_dialog_hint": "Сохраните текущие фильтры как именованное представление, доступное всем.",
"views_name_placeholder": "Название представления",
"views_save_current": "Сохранить текущие фильтры",
"views_none_yet": "Пока нет сохранённых представлений.",
"close": "Закрыть",
"trigger_hint_now": "Сейчас датчик показывает {value}.",
"trigger_hint_above": "Задача сработает, когда значение поднимется выше {target}.",
"trigger_hint_below": "Она сработает, когда значение опустится ниже {target}.",
"trigger_hint_counter_delta": "Отсчёт от текущего показания ({value}): срок при {due} (+{target}); счётчик начинается заново после каждого выполнения.",
"trigger_hint_counter_delta_edit": "Считает расход с последнего выполнения: срок после +{target}; счётчик начинается заново после каждого выполнения.",
"trigger_hint_counter_abs": "Задача наступит, когда датчик достигнет {target}.",
"trigger_hint_runtime": "Задача наступит после {hours} ч суммарной наработки; счётчик начинается заново после каждого выполнения.",
"trigger_hint_state_change": "Задача наступит после {count} изменения(й) состояния.",
"trigger_hint_state_change_to": "Задача наступит после {count} перехода(ов) в «{state}».",
"trigger_hint_state_now": "Текущее состояние: {value}.",
"adopt_problem_part": "Использует деталь: {name}",
"label_filter": "Метка",
"all_labels": "Все метки",
"settings_notify_scope": "Уведомлять только для представления",
"settings_notify_scope_all": "Все задачи",
"settings_notify_scope_hint": "Напоминания отправляют только задачи, совпадающие с фильтрами метки/пользователя выбранного представления. Статус, сортировка и группировка представления здесь игнорируются.",
"card_saved_view": "Сохранённое представление",
"card_saved_view_none": "Нет",
"card_saved_view_help": "Применяет фильтры статуса, пользователя и метки представления в дополнение к фильтрам выше. Сортировка и группировка представления — настройки отображения панели и на карточке не применяются.",
"doc_part_none": "К этой запчасти не привязаны документы."
}
@@ -706,5 +706,65 @@
"series_end_after_count": "Efter ett antal gånger",
"series_end_until": "På ett datum",
"series_end_count_label": "Antal gånger",
"series_end_until_label": "Slutdatum"
"series_end_until_label": "Slutdatum",
"parts_section": "Delar & förbrukning",
"part_add": "Lägg till del",
"part_name": "Namn",
"part_vendor": "Tillverkare",
"part_storage_location": "Förvaringsplats",
"part_product_url": "Produkt-URL",
"part_unit": "Enhet",
"part_cost": "Styckpris",
"part_stock": "Lager",
"part_reorder_threshold": "Beställningsgräns",
"part_restock_quantity": "Påfyllningsantal",
"part_auto_buy": "Köpuppgift vid lågt lager",
"part_restock": "Justera lager",
"restock_quantity_label": "Köpt antal",
"consumes_parts_label": "Förbrukar delar",
"parts_load_failed": "Kunde inte läsa in objektets delar — förbrukningsalternativen är inte tillgängliga just nu.",
"settings_export_selection": "Begränsa till valda objekt (valfritt)",
"settings_docs_archive": "Dokumentarkiv (med filer)",
"settings_docs_archive_hint": "JSON/YAML/CSV-exporterna innehåller bara inställningar. Denna ZIP innehåller de uppladdade filernas innehåll så att en återställning blir fullständig.",
"settings_docs_export_btn": "Ladda ner dokument-ZIP",
"settings_docs_import_btn": "Återställ dokument-ZIP",
"settings_docs_import_success": "Återställt: {blobs} filer, {docs} dokument",
"adopt_problem_button": "Anta problemsensorer",
"adopt_problem_title": "Anta problemsensorer",
"adopt_problem_hint": "Gör HA:s problemsensorer (skrivarfel, filtervarningar, lågt batteri) till underhållsuppgifter som utlöses medan problemet är aktivt och avslutas själva när det löses.",
"adopt_problem_none": "Inga problemsensorer hittades som inte redan spåras.",
"adopt_problem_active": "aktiv",
"adopt_problem_ok": "ok",
"adopt_problem_new_object": "(ny)",
"adopt_problem_adopt": "Anta valda",
"adopt_problem_done": "Antog {tasks} problemsensor(er)",
"views_label": "Vyer",
"views_none": "— Ingen vy —",
"views_manage": "Spara / hantera vyer",
"views_dialog_title": "Sparade vyer",
"views_dialog_hint": "Spara de aktuella filtren som en namngiven vy som alla kan återanvända.",
"views_name_placeholder": "Vynamn",
"views_save_current": "Spara aktuella filter",
"views_none_yet": "Inga sparade vyer ännu.",
"close": "Stäng",
"trigger_hint_now": "Sensorn visar {value} just nu.",
"trigger_hint_above": "Uppgiften utlöses när värdet stiger över {target}.",
"trigger_hint_below": "Den utlöses när värdet faller under {target}.",
"trigger_hint_counter_delta": "Räknar från den aktuella avläsningen ({value}): förfaller vid {due} (+{target}); räknaren börjar om efter varje slutförande.",
"trigger_hint_counter_delta_edit": "Räknar förbrukningen sedan senaste slutförandet: förfaller efter +{target}; räknaren börjar om efter varje slutförande.",
"trigger_hint_counter_abs": "Uppgiften förfaller när sensorn når {target}.",
"trigger_hint_runtime": "Uppgiften förfaller efter {hours} h ackumulerad drifttid; räknaren börjar om efter varje slutförande.",
"trigger_hint_state_change": "Uppgiften förfaller efter {count} tillståndsändring(ar).",
"trigger_hint_state_change_to": "Uppgiften förfaller efter {count} ändring(ar) till ”{state}”.",
"trigger_hint_state_now": "Aktuellt tillstånd: {value}.",
"adopt_problem_part": "Använder reservdel: {name}",
"label_filter": "Etikett",
"all_labels": "Alla etiketter",
"settings_notify_scope": "Avisera endast för vy",
"settings_notify_scope_all": "Alla uppgifter",
"settings_notify_scope_hint": "Endast uppgifter som matchar den valda vyns etikett-/användarfilter skickar påminnelser. Vyns status, sortering och gruppering ignoreras här.",
"card_saved_view": "Sparad vy",
"card_saved_view_none": "Ingen",
"card_saved_view_help": "Tillämpar vyns status-, användar- och etikettfilter utöver filtren ovan. Vyns sortering och gruppering är panelvisningsinställningar och tillämpas inte på kortet.",
"doc_part_none": "Inga dokument länkade till denna del."
}
@@ -706,5 +706,65 @@
"series_end_after_count": "Після кількості разів",
"series_end_until": "У дату",
"series_end_count_label": "Кількість разів",
"series_end_until_label": "Дата завершення"
"series_end_until_label": "Дата завершення",
"parts_section": "Деталі та витратні матеріали",
"part_add": "Додати деталь",
"part_name": "Назва",
"part_vendor": "Виробник",
"part_storage_location": "Місце зберігання",
"part_product_url": "Посилання на товар",
"part_unit": "Одиниця",
"part_cost": "Ціна за од.",
"part_stock": "Запас",
"part_reorder_threshold": "Поріг замовлення",
"part_restock_quantity": "Кількість поповнення",
"part_auto_buy": "Завдання купівлі при малому запасі",
"part_restock": "Змінити запас",
"restock_quantity_label": "Куплена кількість",
"consumes_parts_label": "Витрачає деталі",
"parts_load_failed": "Не вдалося завантажити запчастини цього об'єкта — параметри витрати запчастин зараз недоступні.",
"settings_export_selection": "Обмежити вибраними об'єктами (необов'язково)",
"settings_docs_archive": "Архів документів (з файлами)",
"settings_docs_archive_hint": "Експорти JSON/YAML/CSV містять лише налаштування. Цей ZIP містить вміст завантажених файлів, щоб відновлення було повним.",
"settings_docs_export_btn": "Завантажити ZIP документів",
"settings_docs_import_btn": "Відновити ZIP документів",
"settings_docs_import_success": "Відновлено: {blobs} файлів, {docs} документів",
"adopt_problem_button": "Прийняти сенсори проблем",
"adopt_problem_title": "Прийняти сенсори проблем",
"adopt_problem_hint": "Перетворює сенсори проблем HA (помилки принтера, попередження фільтра, низький заряд) на завдання обслуговування, які спрацьовують, поки проблема активна, і завершуються самі після її усунення.",
"adopt_problem_none": "Не знайдено сенсорів проблем, які ще не відстежуються.",
"adopt_problem_active": "активна",
"adopt_problem_ok": "норма",
"adopt_problem_new_object": "(новий)",
"adopt_problem_adopt": "Прийняти вибрані",
"adopt_problem_done": "Прийнято сенсорів проблем: {tasks}",
"views_label": "Подання",
"views_none": "— Без подання —",
"views_manage": "Зберегти / керувати поданнями",
"views_dialog_title": "Збережені подання",
"views_dialog_hint": "Збережіть поточні фільтри як іменоване подання, доступне всім.",
"views_name_placeholder": "Назва подання",
"views_save_current": "Зберегти поточні фільтри",
"views_none_yet": "Ще немає збережених подань.",
"close": "Закрити",
"trigger_hint_now": "Зараз датчик показує {value}.",
"trigger_hint_above": "Завдання спрацює, коли значення підніметься вище {target}.",
"trigger_hint_below": "Воно спрацює, коли значення опуститься нижче {target}.",
"trigger_hint_counter_delta": "Відлік від поточного показника ({value}): термін при {due} (+{target}); лічильник починається заново після кожного виконання.",
"trigger_hint_counter_delta_edit": "Рахує використання з останнього виконання: термін після +{target}; лічильник починається заново після кожного виконання.",
"trigger_hint_counter_abs": "Завдання настане, коли датчик досягне {target}.",
"trigger_hint_runtime": "Завдання настане після {hours} год сумарного напрацювання; лічильник починається заново після кожного виконання.",
"trigger_hint_state_change": "Завдання настане після {count} зміни(-) стану.",
"trigger_hint_state_change_to": "Завдання настане після {count} переходу(-ів) у «{state}».",
"trigger_hint_state_now": "Поточний стан: {value}.",
"adopt_problem_part": "Використовує деталь: {name}",
"label_filter": "Мітка",
"all_labels": "Усі мітки",
"settings_notify_scope": "Сповіщати лише для подання",
"settings_notify_scope_all": "Усі завдання",
"settings_notify_scope_hint": "Нагадування надсилають лише завдання, що відповідають фільтрам мітки/користувача обраного подання. Статус, сортування та групування подання тут ігноруються.",
"card_saved_view": "Збережене подання",
"card_saved_view_none": "Немає",
"card_saved_view_help": "Застосовує фільтри статусу, користувача та мітки подання додатково до фільтрів вище. Сортування та групування подання — налаштування відображення панелі й на картці не застосовуються.",
"doc_part_none": "До цієї запчастини не прив'язано документів."
}
@@ -706,5 +706,65 @@
"series_end_after_count": "达到次数后",
"series_end_until": "在某日期",
"series_end_count_label": "次数",
"series_end_until_label": "结束日期"
"series_end_until_label": "结束日期",
"parts_section": "配件与耗材",
"part_add": "添加配件",
"part_name": "名称",
"part_vendor": "制造商",
"part_storage_location": "存放位置",
"part_product_url": "商品链接",
"part_unit": "单位",
"part_cost": "单价",
"part_stock": "库存",
"part_reorder_threshold": "补货阈值",
"part_restock_quantity": "补货数量",
"part_auto_buy": "库存低时自动建购买任务",
"part_restock": "调整库存",
"restock_quantity_label": "购买数量",
"consumes_parts_label": "消耗配件",
"parts_load_failed": "无法加载此对象的配件 — 配件消耗选项暂时不可用。",
"settings_export_selection": "仅限所选对象(可选)",
"settings_docs_archive": "文档存档(含文件)",
"settings_docs_archive_hint": "JSON/YAML/CSV 导出仅包含设置。此 ZIP 包含已上传的文件内容,以便完整恢复。",
"settings_docs_export_btn": "下载文档 ZIP",
"settings_docs_import_btn": "恢复文档 ZIP",
"settings_docs_import_success": "已恢复:{blobs} 个文件,{docs} 个文档",
"adopt_problem_button": "采用问题传感器",
"adopt_problem_title": "采用问题传感器",
"adopt_problem_hint": "将 HA 问题传感器(打印机错误、滤网警告、电量低)转换为维护任务:问题存在时触发,问题解决后自动完成。",
"adopt_problem_none": "未找到尚未跟踪的问题传感器。",
"adopt_problem_active": "存在",
"adopt_problem_ok": "正常",
"adopt_problem_new_object": "(新建)",
"adopt_problem_adopt": "采用所选",
"adopt_problem_done": "已采用 {tasks} 个问题传感器",
"views_label": "视图",
"views_none": "— 无视图 —",
"views_manage": "保存/管理视图",
"views_dialog_title": "已保存的视图",
"views_dialog_hint": "将当前筛选保存为所有人都可复用的命名视图。",
"views_name_placeholder": "视图名称",
"views_save_current": "保存当前筛选",
"views_none_yet": "尚无已保存的视图。",
"close": "关闭",
"trigger_hint_now": "传感器当前读数为 {value}。",
"trigger_hint_above": "当数值升到 {target} 以上时任务触发。",
"trigger_hint_below": "当数值降到 {target} 以下时任务触发。",
"trigger_hint_counter_delta": "从当前读数({value})开始计数:到 {due}+{target})时到期;每次完成后重新计数。",
"trigger_hint_counter_delta_edit": "从上次完成开始计算用量:+{target} 后到期;每次完成后重新计数。",
"trigger_hint_counter_abs": "当传感器达到 {target} 时任务到期。",
"trigger_hint_runtime": "累计运行 {hours} 小时后任务到期;每次完成后重新计数。",
"trigger_hint_state_change": "经过 {count} 次状态变化后任务到期。",
"trigger_hint_state_change_to": "变为“{state}”共 {count} 次后任务到期。",
"trigger_hint_state_now": "当前状态:{value}。",
"adopt_problem_part": "使用配件:{name}",
"label_filter": "标签",
"all_labels": "所有标签",
"settings_notify_scope": "仅为该视图发送通知",
"settings_notify_scope_all": "所有任务",
"settings_notify_scope_hint": "只有匹配所选视图的标签/用户筛选的任务才会发送提醒。视图的状态、排序和分组在此处被忽略。",
"card_saved_view": "已保存的视图",
"card_saved_view_none": "无",
"card_saved_view_help": "在上述筛选之外再应用该视图的状态、用户和标签筛选。视图的排序和分组属于面板显示设置,不会应用到卡片上。",
"doc_part_none": "此部件没有关联的文档。"
}
@@ -3,7 +3,7 @@
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { t } from "./styles";
import type { HomeAssistant, CardConfig, MaintenanceObjectResponse } from "./types";
import type { HomeAssistant, CardConfig, MaintenanceObjectResponse, SavedView } from "./types";
const STATUS_KEYS = ["overdue", "triggered", "due_soon", "ok"] as const;
@@ -13,6 +13,7 @@ export class MaintenanceSupporterCardEditor extends LitElement {
@state() private _objects: MaintenanceObjectResponse[] = [];
@state() private _loadingObjects = true;
@state() private _loadError = false;
@state() private _views: SavedView[] = [];
private _objectsLoaded = false;
@@ -44,12 +45,22 @@ export class MaintenanceSupporterCardEditor extends LitElement {
this._loadError = true;
}
this._loadingObjects = false;
// Saved views feed the scope dropdown; best-effort — the editor works
// without them (the dropdown just stays at "none").
try {
const res = await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/views/list",
}) as { views: SavedView[] };
this._views = res.views || [];
} catch {
this._views = [];
}
}
private _valueChanged(key: string, value: unknown): void {
const newConfig = { ...this._config, [key]: value };
// Drop empty arrays so the saved YAML stays clean
if (Array.isArray(value) && value.length === 0) {
// Drop empty arrays / empty strings so the saved YAML stays clean
if ((Array.isArray(value) && value.length === 0) || value === "") {
delete (newConfig as Record<string, unknown>)[key];
}
this._config = newConfig;
@@ -157,6 +168,33 @@ export class MaintenanceSupporterCardEditor extends LitElement {
<div class="field-help">${t("card_filter_entities_help", L)}</div>
</div>
<!-- Saved-view scope (v2.26): applies the view's status/user/label
filters on top of everything above. Hidden while no views exist —
views are created in the panel toolbar, not here. -->
${this._views.length > 0
? html`
<div class="field">
<div class="field-label">${t("card_saved_view", L)}</div>
<select
class="view-select"
.value=${this._config.view_id || ""}
@change=${(e: Event) =>
this._valueChanged("view_id", (e.target as HTMLSelectElement).value)}
>
<option value="" ?selected=${!this._config.view_id}>
${t("card_saved_view_none", L)}
</option>
${this._views.map(
(v) => html`<option value=${v.id} ?selected=${this._config.view_id === v.id}>
${v.name}
</option>`
)}
</select>
<div class="field-help">${t("card_saved_view_help", L)}</div>
</div>
`
: nothing}
<ha-formfield label="${t("card_show_header", L)}">
<ha-switch
.checked=${this._config.show_header !== false}
@@ -263,6 +301,15 @@ export class MaintenanceSupporterCardEditor extends LitElement {
cursor: pointer;
}
.object-row input { cursor: pointer; }
.view-select {
padding: 8px;
border: 1px solid var(--divider-color);
border-radius: 4px;
background: var(--card-background-color, #fff);
color: var(--primary-text-color);
font-size: 14px;
max-width: 320px;
}
`;
}
@@ -9,6 +9,8 @@ import type {
MaintenanceTask,
StatisticsResponse,
CardConfig,
SavedView,
SavedViewFilters,
} from "./types";
import "./maintenance-card-editor";
import "./components/complete-dialog";
@@ -30,6 +32,7 @@ export class MaintenanceSupporterCard extends LitElement {
@state() private _objects: MaintenanceObjectResponse[] = [];
@state() private _stats: StatisticsResponse | null = null;
@state() private _unsub: (() => void) | null = null;
@state() private _viewFilters: SavedViewFilters | null = null;
private get _lang(): string {
return this.hass?.language || "en";
@@ -54,7 +57,13 @@ export class MaintenanceSupporterCard extends LitElement {
}
setConfig(config: CardConfig): void {
const viewChanged = config.view_id !== this._config.view_id;
this._config = config;
// Config can change after the initial load (editor preview) — re-resolve
// the referenced view then; the initial resolve happens in _loadData.
if (viewChanged && this._dataLoaded && this.hass) {
this._loadViewFilters();
}
}
getCardSize(): number {
@@ -113,6 +122,26 @@ export class MaintenanceSupporterCard extends LitElement {
} catch {
// WS not available yet
}
await this._loadViewFilters();
}
/** Resolve the configured saved view's filters (best-effort). A missing or
* deleted view degrades to "no view filter" — same fallback semantics as
* the backend's notification routing, never an inexplicably empty card. */
private async _loadViewFilters(): Promise<void> {
if (!this._config.view_id) {
this._viewFilters = null;
return;
}
try {
const res = await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/views/list",
}) as { views: SavedView[] };
const view = (res.views || []).find((v) => v.id === this._config.view_id);
this._viewFilters = view ? view.filters : null;
} catch {
this._viewFilters = null;
}
}
private async _subscribe(): Promise<void> {
@@ -148,6 +177,14 @@ export class MaintenanceSupporterCard extends LitElement {
const entityFilter = entity_ids?.length ? new Set(entity_ids) : null;
const hasDueRange =
filter_due_min_days !== undefined || filter_due_max_days !== undefined;
// Saved-view scope: unlike the panel (where applying a view REPLACES the
// filter state), the card ANDs the view's task-selecting filters with its
// own config — the card's filters are static YAML, not transient UI state.
// The view's `current_user` sentinel resolves against the logged-in user
// here (client-side), which the backend notification routing cannot do.
const vf = this._viewFilters;
const viewUser =
vf?.user_id === "current_user" ? (this.hass.user?.id ?? null) : (vf?.user_id ?? null);
for (const obj of this._objects) {
if (filter_objects?.length && !filter_objects.includes(obj.object.name)) continue;
@@ -175,6 +212,11 @@ export class MaintenanceSupporterCard extends LitElement {
if (filter_due_min_days !== undefined && days < filter_due_min_days) continue;
if (filter_due_max_days !== undefined && days > filter_due_max_days) continue;
}
if (vf) {
if (vf.status && task.status !== vf.status) continue;
if (vf.label && !(task.labels || []).includes(vf.label)) continue;
if (viewUser && task.responsible_user_id !== viewUser) continue;
}
tasks.push({ entry_id: obj.entry_id, object_name: obj.object.name, task });
}
}
@@ -30,6 +30,8 @@
* On older HA versions the registration is a silent no-op.
*/
import { STATUS_ICONS } from "./status-constants";
interface MaintenanceObjectResp {
entry_id: string;
object: {
@@ -128,10 +130,13 @@ const STATUS_VIEWS: Array<{
icon: string;
path: string;
}> = [
{ status: "overdue", title: "Overdue", icon: "mdi:alert-circle", path: "overdue" },
{ status: "triggered", title: "Triggered", icon: "mdi:flash", path: "triggered" },
{ status: "due_soon", title: "Due Soon", icon: "mdi:clock-alert-outline", path: "due-soon" },
{ status: "ok", title: "OK", icon: "mdi:check-circle-outline", path: "ok" },
// Icons come from the shared STATUS_ICONS so the generated dashboard
// matches the panel (this table used to hardcode its own set — "Overdue"
// wore the panel's due-soon icon).
{ status: "overdue", title: "Overdue", icon: STATUS_ICONS.overdue, path: "overdue" },
{ status: "triggered", title: "Triggered", icon: STATUS_ICONS.triggered, path: "triggered" },
{ status: "due_soon", title: "Due Soon", icon: STATUS_ICONS.due_soon, path: "due-soon" },
{ status: "ok", title: "OK", icon: STATUS_ICONS.ok, path: "ok" },
];
const DUE_DATE_VIEWS: Array<{
@@ -1,8 +1,10 @@
/** Maintenance Supporter Sidebar Panel. */
import { LitElement, html, nothing } from "lit";
import { isSafeHttpUrl } from "./helpers/url";
import { customElement, property, state } from "lit/decorators.js";
import { sharedStyles, STATUS_COLORS, STATUS_ICONS, DEFAULT_CURRENCY_SYMBOL, t, ensureLocale, isLocaleLoaded, formatDate, formatDueDays, formatInterval, formatRecurrence } from "./styles";
import { LS_KEYS } from "./helpers/storage-keys";
import { daysProgress } from "./helpers/interval";
import { buildObjectReportHtml, type ReportLabels } from "./helpers/report";
import { warrantyStatus } from "./helpers/warranty";
@@ -23,12 +25,15 @@ import type {
HistoryEntry,
TriggerConfig,
StatisticsPoint,
SavedView,
SavedViewFilters,
} from "./types";
import { StatisticsService } from "./statistics-service";
import { UserService } from "./user-service";
import "./components/object-dialog";
import type { MaintenanceObjectDialog } from "./components/object-dialog";
import "./components/documents-section";
import "./components/parts-section";
import "./components/task-documents";
import "./components/task-dialog";
import type { MaintenanceTaskDialog } from "./components/task-dialog";
@@ -36,6 +41,8 @@ import "./components/complete-dialog";
import type { MaintenanceCompleteDialog } from "./components/complete-dialog";
import "./components/qr-dialog";
import type { MaintenanceQrDialog } from "./components/qr-dialog";
import "./components/adopt-problem-sensors-dialog";
import type { MaintenanceAdoptProblemSensorsDialog } from "./components/adopt-problem-sensors-dialog";
// v2.0.0: panel uses the extracted Calendar Card instead of its own
// _renderCalendar() method — single source of truth for the calendar view.
import "./maintenance-calendar-card";
@@ -53,6 +60,8 @@ import "./components/seasonal-overrides-dialog";
import type { SeasonalOverridesDialog } from "./components/seasonal-overrides-dialog";
import "./components/group-dialog";
import type { MaintenanceGroupDialog } from "./components/group-dialog";
import "./components/saved-views-dialog";
import type { MaintenanceSavedViewsDialog } from "./components/saved-views-dialog";
import { type SparklineContext } from "./renderers/sparkline";
import { buildCalendarBuckets, isoDateLocal, type CalendarEvent } from "./helpers/calendar-bucket";
import { renderTriggerProgress, renderMiniSparkline } from "./renderers/progress";
@@ -84,17 +93,22 @@ export class MaintenanceSupporterPanel extends LitElement {
@state() private _selectedTaskId: string | null = null;
@state() private _filterStatus = "";
@state() private _filterUser: string | null = null;
@state() private _filterLabel: string | null = null;
// v2.24: shared saved filter views + the id of the one currently applied ("" =
// none; cleared the moment the user hand-edits any filter control).
@state() private _savedViews: SavedView[] = [];
@state() private _activeViewId = "";
@state() private _unsub: (() => void) | null = null;
@state() private _chartRangeDays = (() => {
try {
const v = parseInt(localStorage.getItem("msp-chart-range") || "", 10);
const v = parseInt(localStorage.getItem(LS_KEYS.chartRange) || "", 10);
return [7, 30, 90, 365].includes(v) ? v : 30;
} catch {
return 30;
}
})();
@state() private _hideOutliers = (() => {
try { return localStorage.getItem("msp-chart-hide-outliers") === "1"; } catch { return false; }
try { return localStorage.getItem(LS_KEYS.chartHideOutliers) === "1"; } catch { return false; }
})();
@state() private _historyFilter: string | null = null;
@state() private _budget: BudgetStatus | null = null;
@@ -121,7 +135,7 @@ export class MaintenanceSupporterPanel extends LitElement {
// Dashboard redesign state
@state() private _overviewTab: "today" | "dashboard" | "calendar" | "settings" = (() => {
try {
const v = localStorage.getItem("msp-overview-tab");
const v = localStorage.getItem(LS_KEYS.overviewTab);
return v === "today" || v === "calendar" ? v : "dashboard";
} catch { return "dashboard"; }
})();
@@ -153,7 +167,7 @@ export class MaintenanceSupporterPanel extends LitElement {
// v2.15.0: collapsed analysis sections on the task-detail overview tab,
// remembered per section across visits.
@state() private _collapsedSections: Set<string> = (() => {
try { return new Set(JSON.parse(localStorage.getItem("msp-collapsed-sections") || "[]")); }
try { return new Set(JSON.parse(localStorage.getItem(LS_KEYS.collapsedSections) || "[]")); }
catch { return new Set(); }
})();
// v2.15.0: command palette ("/" since 2.18.1 — Ctrl+K clashed with HA's own
@@ -208,21 +222,29 @@ export class MaintenanceSupporterPanel extends LitElement {
window.addEventListener("popstate", this._popstateHandler);
window.addEventListener("keydown", this._paletteKeydown);
window.addEventListener("resize", this._onVirtualScroll, { passive: true });
const saved = localStorage.getItem("maintenance_supporter_sort");
if (saved && ["due_date", "object", "type", "task_name", "area", "assigned_user", "group"].includes(saved)) {
this._sortMode = saved as SortMode;
}
const savedObj = localStorage.getItem("maintenance_supporter_object_sort");
if (savedObj && ["alphabetical", "due_soonest", "task_count"].includes(savedObj)) {
this._objectSortMode = savedObj as ObjectSortMode;
}
const savedGroup = localStorage.getItem("maintenance_supporter_groupby");
if (savedGroup && ["none", "area", "group", "user"].includes(savedGroup)) {
this._groupByMode = savedGroup as GroupByMode;
}
const savedView = localStorage.getItem("maintenance_supporter_object_view");
if (savedView === "cards" || savedView === "table") {
this._objectViewMode = savedView;
// localStorage.getItem can THROW (Safari private mode / locked-down policies
// where storage access raises rather than returning null) — an unguarded
// read here would abort connectedCallback and leave the panel blank. Every
// other storage access in this file is wrapped; wrap these too.
try {
const saved = localStorage.getItem(LS_KEYS.taskSort);
if (saved && ["due_date", "object", "type", "task_name", "area", "assigned_user", "group"].includes(saved)) {
this._sortMode = saved as SortMode;
}
const savedObj = localStorage.getItem(LS_KEYS.objectSort);
if (savedObj && ["alphabetical", "due_soonest", "task_count"].includes(savedObj)) {
this._objectSortMode = savedObj as ObjectSortMode;
}
const savedGroup = localStorage.getItem(LS_KEYS.groupBy);
if (savedGroup && ["none", "area", "group", "user"].includes(savedGroup)) {
this._groupByMode = savedGroup as GroupByMode;
}
const savedView = localStorage.getItem(LS_KEYS.objectView);
if (savedView === "cards" || savedView === "table") {
this._objectViewMode = savedView;
}
} catch {
// storage blocked — keep the defaults
}
}
@@ -334,13 +356,15 @@ export class MaintenanceSupporterPanel extends LitElement {
}
private async _loadData(): Promise<void> {
const [objResult, statsResult, budgetResult, groupsResult, settingsResult] = await Promise.all([
const [objResult, statsResult, budgetResult, groupsResult, settingsResult, viewsResult] = await Promise.all([
this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/objects" }).catch(() => null),
this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/statistics" }).catch(() => null),
this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/budget_status" }).catch(() => null),
this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/groups" }).catch(() => null),
this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/settings" }).catch(() => null),
this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/views/list" }).catch(() => null),
]);
if (viewsResult) this._savedViews = (viewsResult as { views: SavedView[] }).views || [];
if (objResult) this._objects = (objResult as { objects: MaintenanceObjectResponse[] }).objects;
if (statsResult) this._stats = statsResult as StatisticsResponse;
if (budgetResult) this._budget = budgetResult as BudgetStatus;
@@ -479,7 +503,7 @@ export class MaintenanceSupporterPanel extends LitElement {
private _setChartRange(days: number): void {
if (days === this._chartRangeDays) return;
this._chartRangeDays = days;
try { localStorage.setItem("msp-chart-range", String(days)); } catch { /* private mode */ }
try { localStorage.setItem(LS_KEYS.chartRange, String(days)); } catch { /* private mode */ }
const task = this._selectedEntryId && this._selectedTaskId
? this._getTask(this._selectedEntryId, this._selectedTaskId)
: null;
@@ -498,7 +522,7 @@ export class MaintenanceSupporterPanel extends LitElement {
// Outlier filtering is client-side on the already-fetched series, so just
// flip the flag and let renderChart re-filter — no re-fetch needed.
this._hideOutliers = hide;
try { localStorage.setItem("msp-chart-hide-outliers", hide ? "1" : "0"); } catch { /* private mode */ }
try { localStorage.setItem(LS_KEYS.chartHideOutliers, hide ? "1" : "0"); } catch { /* private mode */ }
}
private async _fetchMiniStatsForOverview(): Promise<void> {
@@ -557,6 +581,9 @@ export class MaintenanceSupporterPanel extends LitElement {
if (task.responsible_user_id !== userId) continue;
}
// Label filter (v2.26 — also captured by saved views)
if (this._filterLabel && !(task.labels || []).includes(this._filterLabel)) continue;
// Collect groups that contain this task
const groupNames: string[] = [];
for (const group of Object.values(this._groups)) {
@@ -695,6 +722,7 @@ export class MaintenanceSupporterPanel extends LitElement {
* the task list. Empty string clears the filter (used by "Tasks" KPI). */
private _filterByStatus(status: string): void {
this._filterStatus = status;
this._activeViewId = "";
// Make sure we're on the dashboard tab so the task list is actually
// visible — pointless to filter on the calendar/settings tabs.
if (this._overviewTab !== "dashboard") {
@@ -703,6 +731,75 @@ export class MaintenanceSupporterPanel extends LitElement {
this._scrollContentToTop();
}
// ── v2.24: saved filter views ──────────────────────────────────────────────
/** Distinct labels across all loaded tasks (for the label-filter dropdown). */
private get _allLabels(): string[] {
const seen = new Set<string>();
for (const obj of this._objects) {
for (const task of obj.tasks) {
for (const lb of task.labels || []) seen.add(lb);
}
}
return [...seen].sort((a, b) => a.localeCompare(b));
}
/** The panel's current task-list filter state, in the shape a view stores. */
private get _currentFilters(): SavedViewFilters {
return {
status: this._filterStatus,
user_id: this._filterUser,
label: this._filterLabel,
archived: this._showArchived,
sort_mode: this._sortMode,
group_by: this._groupByMode,
};
}
/** Apply a saved view's filters to the live controls (or clear on ""). */
private _applyView(viewId: string): void {
this._activeViewId = viewId;
if (!viewId) return;
const view = this._savedViews.find((v) => v.id === viewId);
if (!view) return;
const f = view.filters;
this._filterStatus = f.status || "";
this._filterUser = f.user_id || null;
this._filterLabel = f.label || null;
this._showArchived = !!f.archived;
// Validate against the current enums before assigning — a view saved by an
// older/renamed build could carry a mode no longer valid, which would then
// be persisted to localStorage and silently break sorting/grouping.
if (["due_date", "object", "type", "task_name", "area", "assigned_user", "group"].includes(f.sort_mode)) {
this._sortMode = f.sort_mode as SortMode;
}
if (["none", "area", "group", "user"].includes(f.group_by)) {
this._groupByMode = f.group_by as GroupByMode;
}
// Persist sort/group like the manual controls do, so they stick after reload.
try {
localStorage.setItem(LS_KEYS.taskSort, this._sortMode);
localStorage.setItem(LS_KEYS.groupBy, this._groupByMode);
} catch {
// ignore private-mode storage errors
}
if (this._overviewTab !== "dashboard") this._overviewTab = "dashboard";
}
private _openSavedViewsDialog(): void {
this.shadowRoot!
.querySelector<MaintenanceSavedViewsDialog>("maintenance-saved-views-dialog")
?.open(this._currentFilters, this._savedViews);
}
private _onSavedViewsChanged(e: CustomEvent<{ views: SavedView[] }>): void {
this._savedViews = e.detail.views || [];
// If the applied view was deleted, drop the stale selection.
if (this._activeViewId && !this._savedViews.some((v) => v.id === this._activeViewId)) {
this._activeViewId = "";
}
}
/** Reset scroll on the panel's .content container. Used by every
* navigation action so the user always lands at the top of the new
* view, never at a stale scroll position from the previous view.
@@ -880,6 +977,20 @@ export class MaintenanceSupporterPanel extends LitElement {
`;
}
// --- Adopt problem sensors ---
private _openAdoptProblemSensors(): void {
this.shadowRoot!
.querySelector<MaintenanceAdoptProblemSensorsDialog>("maintenance-adopt-problem-sensors-dialog")
?.open();
}
private _onProblemSensorsAdopted(e: CustomEvent): void {
const tasks = e.detail?.tasks_created ?? 0;
this._showToast(t("adopt_problem_done", this._lang).replace("{tasks}", String(tasks)));
this._loadData();
}
// --- Template gallery ---
private async _openTemplateGallery(): Promise<void> {
@@ -1545,7 +1656,19 @@ export class MaintenanceSupporterPanel extends LitElement {
never: t("worksheet_never", L),
typeLabel: (ty: string) => t(ty, L),
statusLabel: (st: string) => t(st, L),
parts: t("consumes_parts_label", L),
};
// Required parts as checkable lines: qty × name (stock unit) — location.
const wsParts = obj.parts || [];
const partsLines = (task.consumes_parts || [])
.map((link) => {
const pt = wsParts.find((x) => x.id === link.part_id);
if (!pt) return "";
const stock = pt.stock !== null && pt.stock !== undefined ? ` (${pt.stock}${pt.unit ? " " + pt.unit : ""})` : "";
const loc = pt.storage_location ? `${pt.storage_location}` : "";
return `${link.quantity}× ${pt.name}${stock}${loc}`;
})
.filter(Boolean);
const html = buildTaskWorksheetHtml(
task, obj.object.name, labels,
(iso) => formatDate(iso, L),
@@ -1554,6 +1677,7 @@ export class MaintenanceSupporterPanel extends LitElement {
qrComplete?.svg_data_uri || null,
excerpt,
new Date().toISOString(),
partsLines,
);
const url = URL.createObjectURL(new Blob([html], { type: "text/html" }));
window.open(url, "_blank");
@@ -1579,6 +1703,21 @@ export class MaintenanceSupporterPanel extends LitElement {
?.tasks.find((tsk) => tsk.id === taskId);
dlg.taskType = tk?.type || "";
dlg.readingUnit = tk?.reading_unit || "";
// Spare parts: a buy task gets an editable restock-qty field; a consuming
// task shows what it will decrement (incl. the storage location).
const objParts = this._objects.find((o) => o.entry_id === entryId)?.parts || [];
const partById = new Map(objParts.map((pt) => [pt.id, pt]));
const refPart = tk?.part_ref ? partById.get(tk.part_ref.part_id) : undefined;
dlg.restockDefault = tk?.part_ref ? (refPart?.restock_quantity ?? 1) : null;
dlg.consumesInfo = (tk?.consumes_parts || [])
.map((link) => {
const pt = partById.get(link.part_id);
if (!pt) return "";
const loc = pt.storage_location ? `${pt.storage_location}` : "";
const stock = pt.stock !== null && pt.stock !== undefined ? ` (${pt.stock}${pt.unit ? " " + pt.unit : ""})` : "";
return `${link.quantity}× ${pt.name}${stock}${loc}`;
})
.filter(Boolean);
dlg.open();
}
@@ -1649,6 +1788,14 @@ export class MaintenanceSupporterPanel extends LitElement {
.objects=${this._objects}
@group-saved=${this._onDialogEvent}
></maintenance-group-dialog>
<maintenance-adopt-problem-sensors-dialog
.hass=${this.hass}
@problem-sensors-adopted=${(e: CustomEvent) => this._onProblemSensorsAdopted(e)}
></maintenance-adopt-problem-sensors-dialog>
<maintenance-saved-views-dialog
.hass=${this.hass}
@saved-views-changed=${(e: CustomEvent<{ views: SavedView[] }>) => this._onSavedViewsChanged(e)}
></maintenance-saved-views-dialog>
${this._toastMessage ? html`<div class="toast">
<span>${this._toastMessage}</span>
${this._toastUndo ? html`<button class="toast-undo" @click=${() => this._runToastUndo()}>${t("undo", this._lang)}</button>` : nothing}
@@ -1828,7 +1975,7 @@ export class MaintenanceSupporterPanel extends LitElement {
private _setOverviewTab(tab: "today" | "dashboard" | "calendar" | "settings"): void {
this._overviewTab = tab;
try { localStorage.setItem("msp-overview-tab", tab); } catch { /* private mode */ }
try { localStorage.setItem(LS_KEYS.overviewTab, tab); } catch { /* private mode */ }
this._scrollContentToTop();
}
@@ -1901,11 +2048,32 @@ export class MaintenanceSupporterPanel extends LitElement {
${this._features.budget ? this._renderBudgetBar() : nothing}
<div class="filter-bar">
<label class="filter-field">
<span class="filter-label">${t("views_label", L)}</span>
<select
.value=${this._activeViewId}
@change=${(e: Event) => this._applyView((e.target as HTMLSelectElement).value)}
>
<option value="">${t("views_none", L)}</option>
${this._savedViews.map(
(v) => html`<option value=${v.id} ?selected=${this._activeViewId === v.id}>${v.name}</option>`,
)}
</select>
</label>
${!isOperator ? html`
<ha-icon-button
class="views-save-btn"
.path=${"M15,9H5V5H15M12,19A3,3 0 0,1 9,16A3,3 0 0,1 12,13A3,3 0 0,1 15,16A3,3 0 0,1 12,19M17,3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V7L17,3Z"}
.label=${t("views_manage", L)}
title=${t("views_manage", L)}
@click=${() => this._openSavedViewsDialog()}
></ha-icon-button>
` : nothing}
<label class="filter-field">
<span class="filter-label">${t("filter_label", L)}</span>
<select
.value=${this._filterStatus}
@change=${(e: Event) => (this._filterStatus = (e.target as HTMLSelectElement).value)}
@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>
@@ -1921,19 +2089,39 @@ export class MaintenanceSupporterPanel extends LitElement {
@change=${(e: Event) => {
const val = (e.target as HTMLSelectElement).value;
this._filterUser = val || null;
this._activeViewId = "";
}}
>
<option value="">${t("all_users", L)}</option>
<option value="current_user">${t("my_tasks", L)}</option>
</select>
</label>
${this._allLabels.length > 0 ? html`
<label class="filter-field">
<span class="filter-label">${t("label_filter", L)}</span>
<select
.value=${this._filterLabel || ""}
@change=${(e: Event) => {
const val = (e.target as HTMLSelectElement).value;
this._filterLabel = val || null;
this._activeViewId = "";
}}
>
<option value="">${t("all_labels", L)}</option>
${this._allLabels.map(
(lb) => html`<option value=${lb} ?selected=${this._filterLabel === lb}>${lb}</option>`
)}
</select>
</label>
` : nothing}
<label class="filter-field">
<span class="filter-label">${t("sort_label", L)}</span>
<select
.value=${this._sortMode}
@change=${(e: Event) => {
this._sortMode = (e.target as HTMLSelectElement).value as SortMode;
localStorage.setItem("maintenance_supporter_sort", this._sortMode);
this._activeViewId = "";
try { localStorage.setItem(LS_KEYS.taskSort, this._sortMode); } catch { /* private mode */ }
}}
>
<option value="due_date" ?selected=${this._sortMode === "due_date"}>${t("sort_due_date", L)}</option>
@@ -1951,7 +2139,8 @@ export class MaintenanceSupporterPanel extends LitElement {
.value=${this._groupByMode}
@change=${(e: Event) => {
this._groupByMode = (e.target as HTMLSelectElement).value as GroupByMode;
localStorage.setItem("maintenance_supporter_groupby", this._groupByMode);
this._activeViewId = "";
try { localStorage.setItem(LS_KEYS.groupBy, this._groupByMode); } catch { /* private mode */ }
}}
>
<option value="none" ?selected=${this._groupByMode === "none"}>${t("groupby_none", L)}</option>
@@ -1963,7 +2152,7 @@ export class MaintenanceSupporterPanel extends LitElement {
${archivedCount > 0 ? html`
<ha-button
class="archived-toggle ${this._showArchived ? "active" : ""}"
@click=${() => { this._showArchived = !this._showArchived; }}
@click=${() => { this._showArchived = !this._showArchived; this._activeViewId = ""; }}
>
<ha-icon icon="mdi:archive-outline"></ha-icon>
${this._showArchived ? t("hide_archived", L) : `${t("show_archived", L)} (${archivedCount})`}
@@ -1987,6 +2176,9 @@ export class MaintenanceSupporterPanel extends LitElement {
<ha-button @click=${() => this._openTemplateGallery()}>
<ha-icon icon="mdi:view-grid-plus-outline"></ha-icon> ${t("templates_from", L)}
</ha-button>
<ha-button @click=${() => this._openAdoptProblemSensors()}>
<ha-icon icon="mdi:alert-circle-check-outline"></ha-icon> ${t("adopt_problem_button", L)}
</ha-button>
<ha-button
@click=${() => this.shadowRoot!.querySelector<MaintenanceTaskDialog>("maintenance-task-dialog")?.openCreate("", this._objects)}
>
@@ -2265,7 +2457,7 @@ export class MaintenanceSupporterPanel extends LitElement {
.value=${this._objectSortMode}
@change=${(e: Event) => {
this._objectSortMode = (e.target as HTMLSelectElement).value as ObjectSortMode;
localStorage.setItem("maintenance_supporter_object_sort", this._objectSortMode);
localStorage.setItem(LS_KEYS.objectSort, this._objectSortMode);
}}
>
<option value="alphabetical" ?selected=${this._objectSortMode === "alphabetical"}>${t("sort_alphabetical", L)}</option>
@@ -2294,7 +2486,7 @@ export class MaintenanceSupporterPanel extends LitElement {
.value=${this._groupByMode}
@change=${(e: Event) => {
this._groupByMode = (e.target as HTMLSelectElement).value as GroupByMode;
localStorage.setItem("maintenance_supporter_groupby", this._groupByMode);
try { localStorage.setItem(LS_KEYS.groupBy, this._groupByMode); } catch { /* private mode */ }
}}
>
<option value="none" ?selected=${this._groupByMode === "none"}>${t("groupby_none", L)}</option>
@@ -2343,7 +2535,7 @@ export class MaintenanceSupporterPanel extends LitElement {
private _setObjectViewMode(mode: "cards" | "table"): void {
this._objectViewMode = mode;
localStorage.setItem("maintenance_supporter_object_view", mode);
localStorage.setItem(LS_KEYS.objectView, mode);
}
// (#67 / Phase 3) Download all objects as a one-row-per-object CSV.
@@ -2417,7 +2609,7 @@ export class MaintenanceSupporterPanel extends LitElement {
}
case "documentation_url":
return html`<td class="oc-documentation_url">${
o.documentation_url && /^https?:\/\//i.test(o.documentation_url)
isSafeHttpUrl(o.documentation_url)
? html`<a href=${o.documentation_url} target="_blank" rel="noopener noreferrer"
@click=${(e: Event) => e.stopPropagation()}><ha-icon icon="mdi:file-document-outline"></ha-icon></a>`
: "—"
@@ -2716,7 +2908,7 @@ export class MaintenanceSupporterPanel extends LitElement {
? html`<p class="meta">${[o.manufacturer, o.model].filter(Boolean).join(" ")}</p>`
: nothing}
${o.serial_number ? html`<p class="meta">${t("serial_number_label", L)}: ${o.serial_number}</p>` : nothing}
${o.documentation_url && /^https?:\/\//i.test(o.documentation_url)
${isSafeHttpUrl(o.documentation_url)
? html`<p class="meta">${t("documentation_url_label", L)}:
<a href=${o.documentation_url} target="_blank" rel="noopener noreferrer">${o.documentation_url}</a>
</p>`
@@ -2730,12 +2922,6 @@ export class MaintenanceSupporterPanel extends LitElement {
</div>`
: nothing}
<maintenance-documents-section
.hass=${this.hass}
.entryId=${obj.entry_id}
.canWrite=${!isOperator}
></maintenance-documents-section>
<h3>${t("tasks", L)} (${visibleTasks.length})${archivedInObj > 0 ? html`
<ha-button
class="archived-toggle ${this._showArchived ? "active" : ""}"
@@ -2762,6 +2948,9 @@ export class MaintenanceSupporterPanel extends LitElement {
${this._statusBadge(!!task.archived, !!task.is_done, task.status)}
${!task.enabled ? html`<span class="badge-disabled">${t("disabled", L)}</span>` : nothing}
${task.nfc_tag_id ? html`<span class="nfc-badge" title="${t("nfc_linked", L)}"><ha-icon icon="mdi:nfc-variant"></ha-icon></span>` : nothing}
${task.document_count
? 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)}>${task.name}</span>
<span class="task-sub${task.responsible_user_id ? '' : ' task-sub-empty'}">${renderUserBadge(task, (id) => this._userService?.getUserName(id) ?? null)}</span>
@@ -2783,6 +2972,20 @@ export class MaintenanceSupporterPanel extends LitElement {
</span>
</div>
`)}</div>`}
<maintenance-documents-section
.hass=${this.hass}
.entryId=${obj.entry_id}
.canWrite=${!isOperator}
></maintenance-documents-section>
<maintenance-parts-section
.hass=${this.hass}
.entryId=${obj.entry_id}
.parts=${obj.parts || []}
.canWrite=${!isOperator}
@parts-changed=${() => this._loadData()}
></maintenance-parts-section>
</div>
`;
}
@@ -2820,7 +3023,7 @@ export class MaintenanceSupporterPanel extends LitElement {
const next = new Set(this._collapsedSections);
if (next.has(key)) next.delete(key); else next.add(key);
this._collapsedSections = next;
try { localStorage.setItem("msp-collapsed-sections", JSON.stringify([...next])); } catch { /* private mode */ }
try { localStorage.setItem(LS_KEYS.collapsedSections, JSON.stringify([...next])); } catch { /* private mode */ }
}
/** Build the context the history renderers need from panel state. */
File diff suppressed because it is too large Load Diff
@@ -10,18 +10,24 @@
},
"devDependencies": {
"@open-wc/testing": "^4.0.0",
"@web/dev-server-esbuild": "^1.0.0",
"@web/test-runner": "^0.20.0",
"@web/test-runner-playwright": "^0.11.0",
"@web/dev-server-esbuild": "^2.0.0",
"@web/test-runner": "^1.0.0",
"@web/test-runner-playwright": "^1.0.0",
"esbuild": "^0.28.1",
"lit": "^3.3.3",
"typescript": "^6.0.3"
"typescript": "^7.0.2"
},
"dependencies": {
"pdfjs-dist": "^4.10.38",
"playwright": "1.61"
},
"overrides": {
"esbuild": "$esbuild"
"esbuild": "$esbuild",
"@web/dev-server-core": {
"ws": "^7.5.11"
},
"puppeteer-core": {
"ws": "^8.21.0"
}
}
}
@@ -428,23 +428,27 @@ export const panelStyles = css`
text-transform: uppercase;
}
/* Same theme tokens as STATUS_COLORS / .status-badge / .cal-status (this set
predated the token migration and kept bare hex, so it alone ignored custom/
dark themes the 3-palette drift the DRY audit flagged). Dark text on the
light chips (green/orange): white is 2.22.8:1, below the 3:1 WCAG UI floor. */
.status-chip.ok {
background: #4caf50;
color: white;
background: var(--success-color, #4caf50);
color: #000;
}
.status-chip.warning {
background: #ff9800;
color: white;
background: var(--warning-color, #ff9800);
color: #000;
}
.status-chip.overdue {
background: #f44336;
background: var(--error-color, #f44336);
color: white;
}
.status-chip.done {
background: #78909c;
background: var(--maint-done-color, #78909c);
color: white;
}
@@ -600,6 +604,19 @@ export const panelStyles = css`
.cell-badges .priority-badge {
margin-left: 0;
}
/* Right-anchor the auxiliary badges (disabled / NFC / priority chevron) to
the END of the shared badges track. Status pills vary in width per
status AND language (min-width 70px only clamps the short ones "Due
Soon"/"Overdue" overflow it), so left-flowing extras landed at a
different x in every row: the low-priority chevron, which typically sits
next to the short OK pill, fell visibly out of the column formed by the
other rows' chevrons. Anchoring the extras group to the track edge gives
ONE clean column in every language; the virt-sizer row keeps the track
width stable. In the narrow per-row grids the badges area is
content-sized (no free space), so the auto margin is inert there. */
.cell-badges > .status-badge + * {
margin-left: auto;
}
.priority-badge ha-icon {
--mdc-icon-size: 16px;
}
@@ -10,6 +10,7 @@
*/
import { html, nothing } from "lit";
import { isSafeHttpUrl } from "../helpers/url";
import { t, formatDate, formatDateTime, formatRecurrence } from "../styles";
import type { AdvancedFeatures, HomeAssistant, MaintenanceTask } from "../types";
import { renderTriggerSection, type SparklineContext } from "./sparkline";
@@ -205,9 +206,9 @@ function renderChecklistCard(task: MaintenanceTask, ctx: TaskDetailContext) {
* documentation_url for quick access to the device manual without having to
* navigate back to the object detail. */
function renderTaskMeta(task: MaintenanceTask, ctx: TaskDetailContext) {
const safeTaskUrl = task.documentation_url && /^https?:\/\//i.test(task.documentation_url)
const safeTaskUrl = isSafeHttpUrl(task.documentation_url)
? task.documentation_url : null;
const safeObjUrl = ctx.objectDocUrl && /^https?:\/\//i.test(ctx.objectDocUrl) ? ctx.objectDocUrl : null;
const safeObjUrl = isSafeHttpUrl(ctx.objectDocUrl) ? ctx.objectDocUrl : null;
if (!task.notes && !safeTaskUrl && !safeObjUrl) return nothing;
const L = ctx.lang;
return html`
@@ -0,0 +1,34 @@
/** Status colors + icons THE single source for every renderer.
*
* Dependency-free on purpose: the dashboard strategy bundle must stay lean
* (HA's strategy loader has a 5 s whenDefined timeout, and pulling styles.ts
* would drag Lit + locale machinery in). styles.ts re-exports these for the
* panel/card, the strategy imports them directly. A DRY audit (2026-07-10)
* found the strategy hardcoding its own icon set the auto-generated
* "Overdue" view wore the panel's DUE-SOON icon. Import from here instead.
*/
export const STATUS_COLORS: Record<string, string> = {
ok: "var(--success-color, #4caf50)",
due_soon: "var(--warning-color, #ff9800)",
overdue: "var(--error-color, #f44336)",
// Theme-token first so it adapts to dark/custom themes (was a bare #ff5722).
triggered: "var(--deep-orange-color, #ff5722)",
// v2.10.0: archived is a neutral, greyed-out state (retired but retained).
archived: "var(--disabled-color, #9e9e9e)",
// v2.20 (N3): paused is frozen-but-present — info blue, clearly not urgent.
paused: "var(--info-color, #2196f3)",
};
export const STATUS_ICONS: Record<string, string> = {
ok: "mdi:check-circle",
due_soon: "mdi:alert-circle",
overdue: "mdi:alert-octagon",
triggered: "mdi:bell-alert",
archived: "mdi:archive-outline",
paused: "mdi:pause-circle-outline",
completed: "mdi:check-circle",
skipped: "mdi:skip-next",
missed: "mdi:calendar-remove",
reset: "mdi:refresh",
};
@@ -7,30 +7,10 @@ import EN from "./locales/en.json";
// derives the real symbol from const.BUDGET_CURRENCIES[DEFAULT_BUDGET_CURRENCY].
export const DEFAULT_CURRENCY_SYMBOL = "€";
export const STATUS_COLORS: Record<string, string> = {
ok: "var(--success-color, #4caf50)",
due_soon: "var(--warning-color, #ff9800)",
overdue: "var(--error-color, #f44336)",
// Theme-token first so it adapts to dark/custom themes (was a bare #ff5722).
triggered: "var(--deep-orange-color, #ff5722)",
// v2.10.0: archived is a neutral, greyed-out state (retired but retained).
archived: "var(--disabled-color, #9e9e9e)",
// v2.20 (N3): paused is frozen-but-present — info blue, clearly not urgent.
paused: "var(--info-color, #2196f3)",
};
export const STATUS_ICONS: Record<string, string> = {
ok: "mdi:check-circle",
due_soon: "mdi:alert-circle",
overdue: "mdi:alert-octagon",
triggered: "mdi:bell-alert",
archived: "mdi:archive-outline",
paused: "mdi:pause-circle-outline",
completed: "mdi:check-circle",
skipped: "mdi:skip-next",
missed: "mdi:calendar-remove",
reset: "mdi:refresh",
};
// Moved to the dependency-free status-constants.ts so the dashboard strategy
// bundle can share them without pulling Lit in; re-exported here so every
// existing `from "./styles"` import keeps working.
export { STATUS_COLORS, STATUS_ICONS } from "./status-constants";
/* ─── i18n ─── */
@@ -265,12 +245,31 @@ export function fireMoreInfo(ev: Event, entityId: string) {
);
}
/** Native-input field scaffolding for dialogs rendered in the custom-panel
* context, where <ha-textfield> is not registered (the documented
* complete-dialog trap). Shared so the twin copies in confirm-dialog and
* complete-dialog can't drift they already had (12px vs 8px margins). */
export const nativeFieldStyles = css`
.field { display: flex; flex-direction: column; gap: 4px; }
.field-label { font-size: 12px; color: var(--secondary-text-color); }
.field-input {
padding: 8px 10px; font-size: 14px;
background: var(--secondary-background-color, rgba(0,0,0,0.06));
color: var(--primary-text-color);
border: 1px solid var(--divider-color); border-radius: 6px;
font-family: inherit; width: 100%; box-sizing: border-box;
}
.field-input:focus { outline: none; border-color: var(--primary-color); }
`;
export const sharedStyles = css`
:host {
--maint-ok-color: var(--success-color, #4caf50);
--maint-due-soon-color: var(--warning-color, #ff9800);
--maint-overdue-color: var(--error-color, #f44336);
--maint-triggered-color: #ff5722;
/* Theme-token first so it follows dark/custom themes (was a bare #ff5722,
inconsistent with STATUS_COLORS.triggered which already tokenised it). */
--maint-triggered-color: var(--deep-orange-color, #ff5722);
}
.status-badge {
@@ -292,14 +291,17 @@ export const sharedStyles = css`
/* Shape icon so status is not conveyed by colour alone (accessibility). */
.status-badge ha-icon { --mdc-icon-size: 14px; margin-left: -1px; }
.status-badge.ok { background-color: var(--maint-ok-color); }
.status-badge.due_soon { background-color: var(--maint-due-soon-color); }
/* Light-background statuses (green/orange/grey) carry DARK text: white on
them fails even the 3:1 WCAG UI-contrast floor (2.22.8:1), while the
saturated statuses below keep white (3.1:1). Matches the calendar pills. */
.status-badge.ok { background-color: var(--maint-ok-color); color: #000; }
.status-badge.due_soon { background-color: var(--maint-due-soon-color); color: #000; }
.status-badge.overdue { background-color: var(--maint-overdue-color); }
.status-badge.triggered { background-color: var(--maint-triggered-color); }
/* Completed one-time task ("done") — muted blue-grey. */
.status-badge.done { background-color: var(--maint-done-color, #78909c); }
/* v2.10.0: archived (retire-but-retain) — neutral grey, clearly inert. */
.status-badge.archived { background-color: var(--disabled-color, #9e9e9e); }
.status-badge.archived { background-color: var(--disabled-color, #9e9e9e); color: #000; }
/* v2.20 (N3): paused — frozen but present, info blue. */
.status-badge.paused { background-color: var(--info-color, #2196f3); }
@@ -219,6 +219,8 @@ export interface MaintenanceTask {
responsible_user_id?: string | null;
custom_icon?: string | null;
nfc_tag_id?: string | null;
/** Number of documents linked to this task (paperclip badge on the row). */
document_count?: number;
/** v2.20 (#83): display unit for `reading`-type tasks ("kWh", "m³", ...). */
reading_unit?: string | null;
priority?: string | null;
@@ -226,12 +228,41 @@ export interface MaintenanceTask {
// Auto-derived sensor + binary_sensor entity_ids (since 1.0.45)
sensor_entity_id?: string | null;
binary_sensor_entity_id?: string | null;
/** Spare parts consumed by completing this task. */
consumes_parts?: Array<{ part_id: string; quantity: number }> | null;
/** Present on an auto-created "buy" reminder: the owning part. */
part_ref?: { part_id: string } | null;
}
/** A spare part / consumable on an object (full definition + derived state). */
export interface MaintenancePart {
id: string;
name: string;
mpn?: string;
gtin?: string | null;
vendor?: string;
storage_location?: string;
product_url?: string;
notes?: string;
unit?: string;
cost?: number | null;
reorder_threshold?: number | null;
restock_quantity?: number | null;
auto_buy_task?: boolean;
doc_id?: string | null;
/** Tracked on-hand count; null = catalog-only (not tracked). */
stock?: number | null;
/** Derived: tracked stock at/below the reorder threshold. */
is_low?: boolean;
/** Derived: product_url or the configured shopping-search link. */
shopping_url?: string;
}
export interface MaintenanceObjectResponse {
entry_id: string;
object: MaintenanceObject;
tasks: MaintenanceTask[];
parts?: MaintenancePart[];
}
export interface StatisticsResponse {
@@ -244,6 +275,24 @@ export interface StatisticsResponse {
budget?: { currency_symbol?: string } | null;
}
/** A saved filter view: the panel task-list's filter/sort/group state, named
* and shared across users. Mirrors helpers/saved_views.py. */
export interface SavedViewFilters {
status: string;
user_id: string | null;
/** Only tasks carrying this label (v2.26); null = no label filter. */
label?: string | null;
archived: boolean;
sort_mode: string;
group_by: string;
}
export interface SavedView {
id: string;
name: string;
filters: SavedViewFilters;
}
export interface CardConfig {
type: string;
title?: string;
@@ -268,6 +317,11 @@ export interface CardConfig {
filter_due_max_days?: number;
compact?: boolean;
show_actions?: boolean;
// Saved-view scope (v2.26): apply a saved view's task-selecting filters
// (status / user / label) ON TOP of the card's own filters. The view's
// sort/group dimensions are panel display state and are not applied here.
// A deleted view id degrades to "no view filter" instead of an empty card.
view_id?: string;
}
export interface GroupTaskRef {