New apps Added
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
/** Shared mount helper + data factories for full-panel tests.
|
||||
*
|
||||
* Mounting <maintenance-supporter-panel> needs a hass mock with `user` set
|
||||
* (no user → the panel renders read-only "operator" mode) plus handlers for
|
||||
* the five _loadData calls. Used by panel-shell.test.ts (bulk / palette /
|
||||
* Today / virtual table) and panel-deeplink.test.ts (QR scan routing).
|
||||
*/
|
||||
|
||||
import { fixture, html } from "@open-wc/testing";
|
||||
import "../maintenance-panel.js";
|
||||
import { createMockHass, type WsHandler } from "./_test-utils.js";
|
||||
|
||||
let taskSeq = 0;
|
||||
|
||||
export function resetTaskSeq(): void {
|
||||
taskSeq = 0;
|
||||
}
|
||||
|
||||
export function task(over: Record<string, unknown> = {}) {
|
||||
taskSeq++;
|
||||
return {
|
||||
id: `t${taskSeq}`,
|
||||
name: `Task ${String(taskSeq).padStart(3, "0")}`,
|
||||
type: "custom",
|
||||
schedule_type: "time_based",
|
||||
interval_days: 30,
|
||||
warning_days: 7,
|
||||
status: "ok",
|
||||
days_until_due: 10,
|
||||
next_due: "2026-07-15",
|
||||
last_performed: null,
|
||||
trigger_active: false,
|
||||
trigger_current_value: null,
|
||||
trigger_config: null,
|
||||
times_performed: 0,
|
||||
total_cost: 0,
|
||||
average_duration: null,
|
||||
history: [],
|
||||
checklist: [],
|
||||
labels: [],
|
||||
priority: "normal",
|
||||
enabled: true,
|
||||
archived: false,
|
||||
is_done: false,
|
||||
responsible_user_id: null,
|
||||
nfc_tag_id: null,
|
||||
entity_slug: null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
export function obj(entryId: string, tasks: unknown[], name = "Pool Pump") {
|
||||
return {
|
||||
entry_id: entryId,
|
||||
object_id: `obj_${entryId}`,
|
||||
object: {
|
||||
id: `obj_${entryId}`, name, area_id: null, manufacturer: null,
|
||||
model: null, serial_number: null, task_ids: [],
|
||||
},
|
||||
tasks,
|
||||
document_count: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function mountPanel(
|
||||
objects: unknown[],
|
||||
extraHandlers: Record<string, WsHandler> = {},
|
||||
) {
|
||||
const { hass, sent } = createMockHass({
|
||||
handlers: {
|
||||
"maintenance_supporter/objects": () => ({ objects }),
|
||||
"maintenance_supporter/statistics": () => ({
|
||||
total_objects: objects.length, total_tasks: 0,
|
||||
overdue: 0, due_soon: 0, triggered: 0, ok: 0,
|
||||
}),
|
||||
"maintenance_supporter/budget_status": () => ({}),
|
||||
"maintenance_supporter/groups": () => ({ groups: {} }),
|
||||
"maintenance_supporter/documents/list": () => ({ documents: [] }),
|
||||
"maintenance_supporter/task/complete": () => ({ success: true }),
|
||||
"maintenance_supporter/task/archive": () => ({ success: true }),
|
||||
"maintenance_supporter/task/unarchive": () => ({ success: true }),
|
||||
...extraHandlers,
|
||||
},
|
||||
});
|
||||
// The panel derives write access from hass.user (no user → read-only).
|
||||
(hass as Record<string, unknown>).user = { id: "admin-1", is_admin: true };
|
||||
(hass as Record<string, unknown>).areas = {};
|
||||
|
||||
const el = await fixture<HTMLElement & { updateComplete: Promise<unknown> }>(html`
|
||||
<maintenance-supporter-panel
|
||||
.hass=${hass}
|
||||
style="display:block; height: 600px;"
|
||||
></maintenance-supporter-panel>
|
||||
`);
|
||||
// _loadData is async; give it a beat, then settle renders.
|
||||
await new Promise((r) => setTimeout(r, 40));
|
||||
await el.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
await el.updateComplete;
|
||||
return { el, sent };
|
||||
}
|
||||
|
||||
export function sr(el: HTMLElement): ShadowRoot {
|
||||
return el.shadowRoot!;
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Shared test utilities for the panel/card component test suites.
|
||||
*
|
||||
* Centralises the things that were previously duplicated across the per-file
|
||||
* `mockHass()` helpers in settings-view-vacation.test.ts /
|
||||
* settings-view-print-qr.test.ts / task-dialog-completion-actions.test.ts:
|
||||
*
|
||||
* - DEFAULT_FEATURES — the AdvancedFeatures shape with everything OFF
|
||||
* - DEFAULT_SETTINGS_RESPONSE — what the backend's
|
||||
* `maintenance_supporter/settings` WS handler returns out of the box
|
||||
* - createMockHass(...) — returns a stub `{hass, sent, serviceCalls}`
|
||||
* with sendMessagePromise/callService captures and a small handler
|
||||
* map for the most common WS endpoints. Per-suite overrides can be
|
||||
* added via the `handlers` option.
|
||||
*
|
||||
* Underscore-prefixed filename so web-test-runner's `__tests__/**\/*.test.ts`
|
||||
* glob skips it (it's a helper, not a suite).
|
||||
*/
|
||||
|
||||
export interface SentMessage {
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ServiceCall {
|
||||
domain: string;
|
||||
service: string;
|
||||
data?: Record<string, unknown>;
|
||||
/** v2.3.x — separate target arg (matches HA's callService(d, s, data, target)
|
||||
* signature + the production action_listener.py path). Older tests asserted
|
||||
* on data.entity_id; that pattern is now legacy — assert on target.entity_id. */
|
||||
target?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Same shape as `frontend-src/types.ts::AdvancedFeatures`. */
|
||||
export interface MockFeatures {
|
||||
adaptive: boolean;
|
||||
predictions: boolean;
|
||||
seasonal: boolean;
|
||||
environmental: boolean;
|
||||
budget: boolean;
|
||||
groups: boolean;
|
||||
checklists: boolean;
|
||||
schedule_time: boolean;
|
||||
completion_actions: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_FEATURES: MockFeatures = {
|
||||
adaptive: false, predictions: false, seasonal: false,
|
||||
environmental: false, budget: false, groups: false,
|
||||
checklists: false, schedule_time: false, completion_actions: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Default response for `maintenance_supporter/settings` — mirrors the shape
|
||||
* that `_build_full_settings()` in `websocket/dashboard.py` produces with
|
||||
* defaults. Override sub-objects via spread when a test needs a specific value:
|
||||
*
|
||||
* const settings = { ...DEFAULT_SETTINGS_RESPONSE, vacation: {...DEFAULT_SETTINGS_RESPONSE.vacation, enabled: true} };
|
||||
*/
|
||||
export const DEFAULT_SETTINGS_RESPONSE = {
|
||||
features: { ...DEFAULT_FEATURES },
|
||||
admin_panel_user_ids: [] as string[],
|
||||
operator_write_enabled: false,
|
||||
general: {
|
||||
default_warning_days: 7,
|
||||
notifications_enabled: false,
|
||||
notify_service: "",
|
||||
notify_targets: [] as string[],
|
||||
panel_enabled: false,
|
||||
},
|
||||
notifications: {
|
||||
due_soon_enabled: true, due_soon_interval_hours: 24,
|
||||
overdue_enabled: true, overdue_interval_hours: 12,
|
||||
triggered_enabled: true, triggered_interval_hours: 0,
|
||||
quiet_hours_enabled: true, quiet_hours_start: "22:00", quiet_hours_end: "08:00",
|
||||
max_per_day: 0, bundling_enabled: false, bundle_threshold: 2,
|
||||
title_style: "default",
|
||||
},
|
||||
actions: {
|
||||
complete_enabled: false, skip_enabled: false,
|
||||
snooze_enabled: false, snooze_duration_hours: 4,
|
||||
},
|
||||
budget: {
|
||||
monthly: 0, yearly: 0, alerts_enabled: false,
|
||||
alert_threshold_pct: 80, currency: "EUR", currency_symbol: "€",
|
||||
},
|
||||
vacation: {
|
||||
enabled: false, start: null as string | null, end: null as string | null,
|
||||
buffer_days: 3, exempt_task_ids: [] as string[],
|
||||
is_active: false, window_end: null as string | null,
|
||||
},
|
||||
};
|
||||
|
||||
export type WsHandler = (msg: SentMessage) => Promise<unknown> | unknown;
|
||||
|
||||
export interface CreateMockHassResult {
|
||||
hass: {
|
||||
language: string;
|
||||
connection: { sendMessagePromise: (msg: SentMessage) => Promise<unknown> };
|
||||
callService: (
|
||||
domain: string, service: string,
|
||||
data?: Record<string, unknown>, target?: Record<string, unknown>,
|
||||
) => Promise<void>;
|
||||
services?: Record<string, Record<string, unknown>>;
|
||||
states?: Record<string, unknown>;
|
||||
};
|
||||
sent: SentMessage[];
|
||||
serviceCalls: ServiceCall[];
|
||||
}
|
||||
|
||||
export interface CreateMockHassOptions {
|
||||
/** Override the default settings response (deep-merge not done — pass full shape). */
|
||||
settingsResponse?: typeof DEFAULT_SETTINGS_RESPONSE;
|
||||
/** Per-WS-type handlers — return a value or Promise. Wins over built-in defaults. */
|
||||
handlers?: Record<string, WsHandler>;
|
||||
/** Optional `hass.services` registry (for ha-service-picker / schema-driven forms). */
|
||||
services?: Record<string, Record<string, unknown>>;
|
||||
/** Optional `hass.states` (entity_id → state) — e.g. for notify-entity pickers. */
|
||||
states?: Record<string, unknown>;
|
||||
/** Override `hass.language`. Defaults to "en". */
|
||||
language?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a stub `hass` object suitable for mounting Lit components in
|
||||
* @open-wc/testing fixtures. Captures all outgoing WS messages in `sent`
|
||||
* and all service calls in `serviceCalls` so tests can assert on them.
|
||||
*
|
||||
* Built-in handlers (overridable via `opts.handlers`):
|
||||
* - maintenance_supporter/settings → DEFAULT_SETTINGS_RESPONSE (or the override)
|
||||
* - maintenance_supporter/users/list → {users: []}
|
||||
* - maintenance_supporter/objects → {objects: []}
|
||||
* - maintenance_supporter/tags/list → {tags: []}
|
||||
* - default for anything else → {}
|
||||
*/
|
||||
export function createMockHass(opts: CreateMockHassOptions = {}): CreateMockHassResult {
|
||||
const sent: SentMessage[] = [];
|
||||
const serviceCalls: ServiceCall[] = [];
|
||||
const settings = opts.settingsResponse ?? DEFAULT_SETTINGS_RESPONSE;
|
||||
|
||||
const sendMessagePromise = async (msg: SentMessage): Promise<unknown> => {
|
||||
sent.push(msg);
|
||||
const override = opts.handlers?.[msg.type];
|
||||
if (override) return await override(msg);
|
||||
if (msg.type === "maintenance_supporter/settings") return settings;
|
||||
if (msg.type === "maintenance_supporter/users/list") return { users: [] };
|
||||
if (msg.type === "maintenance_supporter/objects") return { objects: [] };
|
||||
if (msg.type === "maintenance_supporter/tags/list") return { tags: [] };
|
||||
return {};
|
||||
};
|
||||
|
||||
const callService = async (
|
||||
domain: string, service: string,
|
||||
data?: Record<string, unknown>, target?: Record<string, unknown>,
|
||||
): Promise<void> => {
|
||||
serviceCalls.push({ domain, service, data, target });
|
||||
};
|
||||
|
||||
return {
|
||||
hass: {
|
||||
language: opts.language ?? "en",
|
||||
connection: { sendMessagePromise },
|
||||
callService,
|
||||
services: opts.services,
|
||||
states: opts.states,
|
||||
},
|
||||
sent,
|
||||
serviceCalls,
|
||||
};
|
||||
}
|
||||
+465
@@ -0,0 +1,465 @@
|
||||
/**
|
||||
* Pure-function tests for the Calendar tab's bucketing + recurring projection
|
||||
* helper (v1.5.0).
|
||||
*
|
||||
* Pins:
|
||||
* - tasks with next_due in the window land on the right day
|
||||
* - tasks with next_due before the window are excluded (unless overdue)
|
||||
* - overdue / triggered tasks bucket on "today" (windowStart)
|
||||
* - time-based tasks project up to MAX_OCCURRENCES_PER_TASK occurrences
|
||||
* - sensor-triggered tasks DO NOT get projected occurrences
|
||||
* - user filter restricts to tasks with matching responsible_user_id
|
||||
* - status sort within a day: overdue < triggered < due_soon < ok
|
||||
* - disabled tasks never produce events
|
||||
*/
|
||||
|
||||
import { expect } from "@open-wc/testing";
|
||||
import {
|
||||
buildCalendarBuckets,
|
||||
buildPastBuckets,
|
||||
buildPastWindowDates,
|
||||
isoDateLocal,
|
||||
MAX_OCCURRENCES_PER_TASK,
|
||||
} from "../helpers/calendar-bucket";
|
||||
|
||||
const TODAY = new Date(2026, 4, 1); // 2026-05-01 local
|
||||
const TODAY_ISO = "2026-05-01";
|
||||
|
||||
function addDays(iso: string, days: number): string {
|
||||
const [y, m, d] = iso.split("-").map(Number);
|
||||
const date = new Date(y, m - 1, d);
|
||||
date.setDate(date.getDate() + days);
|
||||
return isoDateLocal(date);
|
||||
}
|
||||
|
||||
function task(over: Partial<any> = {}) {
|
||||
return {
|
||||
id: "t1",
|
||||
name: "Filter Replacement",
|
||||
enabled: true,
|
||||
schedule_type: "time_based",
|
||||
interval_days: 30,
|
||||
status: "ok",
|
||||
next_due: addDays(TODAY_ISO, 5),
|
||||
days_until_due: 5,
|
||||
history: [],
|
||||
responsible_user_id: null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function obj(name: string, tasks: any[]) {
|
||||
return {
|
||||
entry_id: `entry-${name.toLowerCase().replace(/\s+/g, "-")}`,
|
||||
object: { id: `o-${name}`, name, area_id: null, manufacturer: null,
|
||||
model: null, serial_number: null, installation_date: null },
|
||||
tasks,
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe("buildCalendarBuckets", () => {
|
||||
it("returns one bucket per day in the window", () => {
|
||||
const buckets = buildCalendarBuckets([], TODAY, 7);
|
||||
expect(buckets).to.have.length(7);
|
||||
expect(buckets[0].date).to.equal(TODAY_ISO);
|
||||
expect(buckets[6].date).to.equal(addDays(TODAY_ISO, 6));
|
||||
});
|
||||
|
||||
it("buckets a single task on its next_due day", () => {
|
||||
const buckets = buildCalendarBuckets(
|
||||
[obj("HVAC", [task({ next_due: addDays(TODAY_ISO, 3) })])],
|
||||
TODAY, 7
|
||||
);
|
||||
expect(buckets[3].events).to.have.length(1);
|
||||
expect(buckets[3].events[0].task_name).to.equal("Filter Replacement");
|
||||
expect(buckets[3].events[0].projected).to.equal(false);
|
||||
});
|
||||
|
||||
it("excludes tasks whose next_due is before the window and not overdue", () => {
|
||||
const buckets = buildCalendarBuckets(
|
||||
[obj("HVAC", [task({ next_due: addDays(TODAY_ISO, -10), status: "ok" })])],
|
||||
TODAY, 7
|
||||
);
|
||||
const total = buckets.reduce((s, b) => s + b.events.length, 0);
|
||||
expect(total).to.equal(0);
|
||||
});
|
||||
|
||||
it("buckets overdue tasks on today regardless of next_due", () => {
|
||||
const buckets = buildCalendarBuckets(
|
||||
[obj("HVAC", [task({
|
||||
next_due: addDays(TODAY_ISO, -15),
|
||||
status: "overdue",
|
||||
days_until_due: -15,
|
||||
interval_days: 90, // 90d > 7d window → no projection within window
|
||||
})])],
|
||||
TODAY, 7
|
||||
);
|
||||
expect(buckets[0].events).to.have.length(1);
|
||||
expect(buckets[0].events[0].status).to.equal("overdue");
|
||||
expect(buckets[0].events[0].projected).to.equal(false);
|
||||
});
|
||||
|
||||
it("projects time-based recurring occurrences within the window", () => {
|
||||
const buckets = buildCalendarBuckets(
|
||||
[obj("Pool", [task({
|
||||
next_due: addDays(TODAY_ISO, 2),
|
||||
interval_days: 7,
|
||||
schedule_type: "time_based",
|
||||
})])],
|
||||
TODAY, 30
|
||||
);
|
||||
// Window covers days 0..29. From next_due=+2 with step=7: +2, +9, +16, +23.
|
||||
// Next would be +30 which is outside the window, so 4 events total.
|
||||
const events = buckets.flatMap((b) => b.events);
|
||||
expect(events.length).to.equal(4);
|
||||
expect(events[0].projected).to.equal(false);
|
||||
expect(events.slice(1).every((e) => e.projected)).to.equal(true);
|
||||
expect(events[0].date).to.equal(addDays(TODAY_ISO, 2));
|
||||
expect(events[1].date).to.equal(addDays(TODAY_ISO, 9));
|
||||
expect(events[3].date).to.equal(addDays(TODAY_ISO, 23));
|
||||
});
|
||||
|
||||
it("caps projection at MAX_OCCURRENCES_PER_TASK", () => {
|
||||
// 1-day interval with 30-day window would otherwise produce 30+ events
|
||||
const buckets = buildCalendarBuckets(
|
||||
[obj("Daily", [task({
|
||||
next_due: TODAY_ISO,
|
||||
interval_days: 1,
|
||||
schedule_type: "time_based",
|
||||
days_until_due: 0,
|
||||
})])],
|
||||
TODAY, 30
|
||||
);
|
||||
const events = buckets.flatMap((b) => b.events);
|
||||
expect(events.length).to.equal(MAX_OCCURRENCES_PER_TASK);
|
||||
});
|
||||
|
||||
it("a months/years interval does not project at daily steps (issue #59)", () => {
|
||||
// interval_days=1 + unit=years → one occurrence per ~year, so a 30-day
|
||||
// window holds only the single next_due, NOT 5 daily-spaced projections.
|
||||
const buckets = buildCalendarBuckets(
|
||||
[obj("Boiler", [task({
|
||||
next_due: addDays(TODAY_ISO, 3),
|
||||
days_until_due: 3,
|
||||
interval_days: 1,
|
||||
interval_unit: "years",
|
||||
schedule_type: "time_based",
|
||||
})])],
|
||||
TODAY, 30
|
||||
);
|
||||
const events = buckets.flatMap((b) => b.events);
|
||||
expect(events.length).to.equal(1);
|
||||
expect(events[0].projected).to.equal(false);
|
||||
});
|
||||
|
||||
it("projects a weekly (unit=weeks) task at ~7-day steps (#59)", () => {
|
||||
const buckets = buildCalendarBuckets(
|
||||
[obj("Pool", [task({
|
||||
next_due: addDays(TODAY_ISO, 2),
|
||||
days_until_due: 2,
|
||||
interval_days: 1,
|
||||
interval_unit: "weeks",
|
||||
schedule_type: "time_based",
|
||||
})])],
|
||||
TODAY, 30
|
||||
);
|
||||
const events = buckets.flatMap((b) => b.events);
|
||||
// +2, +9, +16, +23 — same as interval_days:7, NOT daily spam.
|
||||
expect(events.length).to.equal(4);
|
||||
expect(events[0].date).to.equal(addDays(TODAY_ISO, 2));
|
||||
expect(events[1].date).to.equal(addDays(TODAY_ISO, 9));
|
||||
});
|
||||
|
||||
it("projects a monthly (unit=months) task at ~30-day steps (#59)", () => {
|
||||
const buckets = buildCalendarBuckets(
|
||||
[obj("Filter", [task({
|
||||
next_due: addDays(TODAY_ISO, 3),
|
||||
days_until_due: 3,
|
||||
interval_days: 1,
|
||||
interval_unit: "months",
|
||||
schedule_type: "time_based",
|
||||
})])],
|
||||
TODAY, 70
|
||||
);
|
||||
const events = buckets.flatMap((b) => b.events);
|
||||
// round(30.4368) = 30 → +3, +33, +63 within a 70-day window.
|
||||
expect(events.length).to.equal(3);
|
||||
expect(events[0].date).to.equal(addDays(TODAY_ISO, 3));
|
||||
expect(events[1].date).to.equal(addDays(TODAY_ISO, 33));
|
||||
});
|
||||
|
||||
it("does NOT project sensor-triggered tasks beyond next_due", () => {
|
||||
const buckets = buildCalendarBuckets(
|
||||
[obj("HVAC", [task({
|
||||
schedule_type: "sensor_based",
|
||||
interval_days: 7, // ignored — sensor tasks don't project
|
||||
next_due: addDays(TODAY_ISO, 3),
|
||||
})])],
|
||||
TODAY, 30
|
||||
);
|
||||
const events = buckets.flatMap((b) => b.events);
|
||||
expect(events).to.have.length(1);
|
||||
expect(events[0].projected).to.equal(false);
|
||||
});
|
||||
|
||||
it("respects the user filter via responsible_user_id", () => {
|
||||
const buckets = buildCalendarBuckets(
|
||||
[obj("HVAC", [
|
||||
task({ id: "t-alice", responsible_user_id: "alice", next_due: addDays(TODAY_ISO, 1) }),
|
||||
task({ id: "t-bob", responsible_user_id: "bob", next_due: addDays(TODAY_ISO, 1) }),
|
||||
task({ id: "t-none", responsible_user_id: null, next_due: addDays(TODAY_ISO, 1) }),
|
||||
])],
|
||||
TODAY, 7, "alice"
|
||||
);
|
||||
const events = buckets.flatMap((b) => b.events);
|
||||
expect(events).to.have.length(1);
|
||||
expect(events[0].task_id).to.equal("t-alice");
|
||||
});
|
||||
|
||||
it("includes all users when filter is null", () => {
|
||||
const buckets = buildCalendarBuckets(
|
||||
[obj("HVAC", [
|
||||
task({ id: "t-alice", responsible_user_id: "alice", next_due: addDays(TODAY_ISO, 1) }),
|
||||
task({ id: "t-bob", responsible_user_id: "bob", next_due: addDays(TODAY_ISO, 1) }),
|
||||
])],
|
||||
TODAY, 7, null
|
||||
);
|
||||
const events = buckets.flatMap((b) => b.events);
|
||||
expect(events).to.have.length(2);
|
||||
});
|
||||
|
||||
it("orders within a day: overdue < triggered < due_soon < ok", () => {
|
||||
const due = addDays(TODAY_ISO, 0);
|
||||
const buckets = buildCalendarBuckets(
|
||||
[obj("HVAC", [
|
||||
task({ id: "t-ok", status: "ok", name: "OK Task", next_due: due, interval_days: 9999 }),
|
||||
task({ id: "t-overdue", status: "overdue", name: "Overdue Task",
|
||||
next_due: due, days_until_due: -5, interval_days: 9999 }),
|
||||
task({ id: "t-triggered", status: "triggered", name: "Triggered Task",
|
||||
schedule_type: "sensor_based", next_due: due, interval_days: null }),
|
||||
task({ id: "t-due_soon", status: "due_soon", name: "Due Soon Task",
|
||||
next_due: due, interval_days: 9999 }),
|
||||
])],
|
||||
TODAY, 7
|
||||
);
|
||||
const todayEvents = buckets[0].events;
|
||||
expect(todayEvents.map((e) => e.status)).to.deep.equal([
|
||||
"overdue", "triggered", "due_soon", "ok",
|
||||
]);
|
||||
});
|
||||
|
||||
it("excludes disabled tasks", () => {
|
||||
const buckets = buildCalendarBuckets(
|
||||
[obj("HVAC", [task({ enabled: false, next_due: addDays(TODAY_ISO, 1) })])],
|
||||
TODAY, 7
|
||||
);
|
||||
const events = buckets.flatMap((b) => b.events);
|
||||
expect(events).to.have.length(0);
|
||||
});
|
||||
|
||||
it("buckets first occurrence + projected ones for a 14-day window task", () => {
|
||||
const buckets = buildCalendarBuckets(
|
||||
[obj("Pool", [task({ next_due: addDays(TODAY_ISO, 1), interval_days: 14 })])],
|
||||
TODAY, 30
|
||||
);
|
||||
const events = buckets.flatMap((b) => b.events);
|
||||
expect(events.length).to.equal(3); // +1, +15, +29
|
||||
expect(events[0].date).to.equal(addDays(TODAY_ISO, 1));
|
||||
expect(events[1].date).to.equal(addDays(TODAY_ISO, 15));
|
||||
expect(events[2].date).to.equal(addDays(TODAY_ISO, 29));
|
||||
});
|
||||
|
||||
// v1.5.1: source indicator + prediction confidence
|
||||
it("flags adaptive_enabled when adaptive_config.enabled is true", () => {
|
||||
const buckets = buildCalendarBuckets(
|
||||
[obj("HVAC", [task({
|
||||
next_due: addDays(TODAY_ISO, 5),
|
||||
adaptive_config: { enabled: true },
|
||||
})])],
|
||||
TODAY, 7
|
||||
);
|
||||
const events = buckets.flatMap((b) => b.events);
|
||||
expect(events).to.have.length(1);
|
||||
expect(events[0].adaptive_enabled).to.equal(true);
|
||||
expect(events[0].prediction_confidence).to.equal(null);
|
||||
});
|
||||
|
||||
it("propagates threshold_prediction_confidence for sensor-based tasks", () => {
|
||||
const buckets = buildCalendarBuckets(
|
||||
[obj("Pool", [task({
|
||||
schedule_type: "sensor_based",
|
||||
next_due: addDays(TODAY_ISO, 4),
|
||||
threshold_prediction_confidence: "high",
|
||||
interval_days: null,
|
||||
})])],
|
||||
TODAY, 14
|
||||
);
|
||||
const events = buckets.flatMap((b) => b.events);
|
||||
expect(events).to.have.length(1);
|
||||
expect(events[0].schedule_type).to.equal("sensor_based");
|
||||
expect(events[0].prediction_confidence).to.equal("high");
|
||||
expect(events[0].adaptive_enabled).to.equal(false);
|
||||
});
|
||||
|
||||
it("downgrades status of projected occurrences from an overdue task to ok", () => {
|
||||
// The May 7 projection of an overdue task should NOT inherit "overdue"
|
||||
// — the projection is the assumption that the user completes today, so
|
||||
// the projected slot is hypothetical and starts fresh.
|
||||
const buckets = buildCalendarBuckets(
|
||||
[obj("Pool", [task({
|
||||
next_due: addDays(TODAY_ISO, -10),
|
||||
status: "overdue",
|
||||
days_until_due: -10,
|
||||
interval_days: 7,
|
||||
})])],
|
||||
TODAY, 30
|
||||
);
|
||||
const events = buckets.flatMap((b) => b.events);
|
||||
expect(events.length).to.be.greaterThan(1);
|
||||
expect(events[0].status).to.equal("overdue"); // first occurrence on today
|
||||
expect(events[0].projected).to.equal(false);
|
||||
// All projected occurrences should read "ok"
|
||||
for (const e of events.slice(1)) {
|
||||
expect(e.projected).to.equal(true);
|
||||
expect(e.status).to.equal("ok");
|
||||
expect(e.days_until_due).to.equal(null);
|
||||
}
|
||||
});
|
||||
|
||||
it("defaults source fields to (false, null) when no metadata is present", () => {
|
||||
const buckets = buildCalendarBuckets(
|
||||
[obj("HVAC", [task({ next_due: addDays(TODAY_ISO, 2) })])],
|
||||
TODAY, 7
|
||||
);
|
||||
const events = buckets.flatMap((b) => b.events);
|
||||
expect(events).to.have.length(1);
|
||||
expect(events[0].adaptive_enabled).to.equal(false);
|
||||
expect(events[0].prediction_confidence).to.equal(null);
|
||||
});
|
||||
});
|
||||
|
||||
// ── v2.2.0 — past-window bucketer ────────────────────────────────────────
|
||||
|
||||
function histEntry(daysAgo: number, type = "completed", over: Partial<any> = {}) {
|
||||
const d = new Date(TODAY);
|
||||
d.setDate(d.getDate() - daysAgo);
|
||||
return {
|
||||
timestamp: `${isoDateLocal(d)}T08:30:00`,
|
||||
type,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function pastTask(history: any[], over: Partial<any> = {}) {
|
||||
return {
|
||||
...task({ history }),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildPastWindowDates", () => {
|
||||
it("returns N consecutive ISO dates ending on today", () => {
|
||||
const dates = buildPastWindowDates(TODAY, 7);
|
||||
expect(dates).to.have.length(7);
|
||||
expect(dates[6]).to.equal(TODAY_ISO);
|
||||
expect(dates[0]).to.equal(addDays(TODAY_ISO, -6));
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPastBuckets", () => {
|
||||
it("returns one bucket per day in the past window", () => {
|
||||
const buckets = buildPastBuckets([], TODAY, 30);
|
||||
expect(buckets).to.have.length(30);
|
||||
expect(buckets[29].date).to.equal(TODAY_ISO);
|
||||
expect(buckets[0].date).to.equal(addDays(TODAY_ISO, -29));
|
||||
});
|
||||
|
||||
it("buckets a completion entry on its actual date", () => {
|
||||
const buckets = buildPastBuckets(
|
||||
[obj("HVAC", [pastTask([histEntry(5)])])],
|
||||
TODAY, 30,
|
||||
);
|
||||
const events = buckets.flatMap((b) => b.events);
|
||||
expect(events).to.have.length(1);
|
||||
expect(events[0].date).to.equal(addDays(TODAY_ISO, -5));
|
||||
expect(events[0].history_timestamp).to.match(/^[0-9-]{10}T08:30:00$/);
|
||||
expect(events[0].history_type).to.equal("completed");
|
||||
});
|
||||
|
||||
it("excludes entries outside the window", () => {
|
||||
const buckets = buildPastBuckets(
|
||||
[obj("HVAC", [pastTask([
|
||||
histEntry(5), // inside 30-day window
|
||||
histEntry(40), // outside
|
||||
])])],
|
||||
TODAY, 30,
|
||||
);
|
||||
const events = buckets.flatMap((b) => b.events);
|
||||
expect(events).to.have.length(1);
|
||||
});
|
||||
|
||||
it("preserves cost / notes / duration from history entries", () => {
|
||||
const buckets = buildPastBuckets(
|
||||
[obj("HVAC", [pastTask([
|
||||
histEntry(3, "completed", { cost: 42.5, notes: "n1", duration: 60 }),
|
||||
])])],
|
||||
TODAY, 30,
|
||||
);
|
||||
const ev = buckets.flatMap((b) => b.events)[0];
|
||||
expect(ev.history_cost).to.equal(42.5);
|
||||
expect(ev.history_notes).to.equal("n1");
|
||||
expect(ev.history_duration).to.equal(60);
|
||||
});
|
||||
|
||||
it("maps history types to status correctly", () => {
|
||||
const buckets = buildPastBuckets(
|
||||
[obj("HVAC", [pastTask([
|
||||
histEntry(1, "completed"),
|
||||
histEntry(2, "skipped"),
|
||||
histEntry(3, "triggered"),
|
||||
])])],
|
||||
TODAY, 30,
|
||||
);
|
||||
const events = buckets.flatMap((b) => b.events);
|
||||
const byType = Object.fromEntries(events.map((e) => [e.history_type, e.status]));
|
||||
expect(byType.completed).to.equal("ok");
|
||||
expect(byType.skipped).to.equal("due_soon");
|
||||
expect(byType.triggered).to.equal("triggered");
|
||||
});
|
||||
|
||||
it("respects user filter", () => {
|
||||
const buckets = buildPastBuckets(
|
||||
[obj("HVAC", [
|
||||
pastTask([histEntry(5)], { id: "a", responsible_user_id: "u1" }),
|
||||
pastTask([histEntry(5)], { id: "b", responsible_user_id: "u2" }),
|
||||
])],
|
||||
TODAY, 30, "u1",
|
||||
);
|
||||
const events = buckets.flatMap((b) => b.events);
|
||||
expect(events).to.have.length(1);
|
||||
expect(events[0].task_id).to.equal("a");
|
||||
});
|
||||
|
||||
it("sorts within day by type rank then by name", () => {
|
||||
const buckets = buildPastBuckets(
|
||||
[obj("HVAC", [
|
||||
pastTask([histEntry(5, "triggered")], { id: "z", name: "Z task" }),
|
||||
pastTask([histEntry(5, "completed")], { id: "a", name: "A task" }),
|
||||
pastTask([histEntry(5, "completed")], { id: "m", name: "M task" }),
|
||||
])],
|
||||
TODAY, 30,
|
||||
);
|
||||
const dayEvents = buckets.find((b) => b.date === addDays(TODAY_ISO, -5))!.events;
|
||||
expect(dayEvents.map((e) => e.task_id)).to.deep.equal(["a", "m", "z"]);
|
||||
});
|
||||
|
||||
it("history_timestamp survives so the edit-history WS can use it", () => {
|
||||
const ts = "2026-04-25T14:00:00";
|
||||
const buckets = buildPastBuckets(
|
||||
[obj("HVAC", [pastTask([{ timestamp: ts, type: "completed" }])])],
|
||||
TODAY, 30,
|
||||
);
|
||||
const ev = buckets.flatMap((b) => b.events)[0];
|
||||
expect(ev.history_timestamp).to.equal(ts);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Tests for the standalone <maintenance-supporter-calendar-card> (audit #10).
|
||||
*
|
||||
* The bucketing math is deep-tested in calendar-bucket.test.ts; this covers
|
||||
* the card behaviours on top of it:
|
||||
* - window chips re-bucket the events (an event beyond the window disappears)
|
||||
* - projected recurrences render with the projected class; the real
|
||||
* next-due event does not
|
||||
* - clicking an event fires the ll-custom open-task payload the panel and
|
||||
* dialog-mount listen for
|
||||
*/
|
||||
|
||||
import { expect, fixture, html } from "@open-wc/testing";
|
||||
import "../maintenance-calendar-card.js";
|
||||
import { createMockHass } from "./_test-utils.js";
|
||||
|
||||
type CardEl = HTMLElement & { updateComplete: Promise<unknown> };
|
||||
|
||||
function isoInDays(n: number): string {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + n);
|
||||
const p = (x: number) => String(x).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
||||
}
|
||||
|
||||
function task(over: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "t1",
|
||||
name: "Filter",
|
||||
type: "custom",
|
||||
schedule_type: "time_based",
|
||||
interval_days: 30,
|
||||
warning_days: 7,
|
||||
status: "ok",
|
||||
days_until_due: 5,
|
||||
next_due: isoInDays(5),
|
||||
trigger_active: false,
|
||||
history: [],
|
||||
enabled: true,
|
||||
archived: false,
|
||||
responsible_user_id: null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
async function mount(tasks: unknown[]) {
|
||||
const { hass } = createMockHass({
|
||||
handlers: {
|
||||
"maintenance_supporter/objects": () => ({
|
||||
objects: [{
|
||||
entry_id: "e1",
|
||||
object: { id: "o1", name: "Pool Pump", area_id: null, task_ids: [] },
|
||||
tasks,
|
||||
}],
|
||||
}),
|
||||
"maintenance_supporter/statistics": () => ({}),
|
||||
},
|
||||
});
|
||||
const el = await fixture<CardEl>(html`
|
||||
<maintenance-supporter-calendar-card .hass=${hass}></maintenance-supporter-calendar-card>
|
||||
`);
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
await el.updateComplete;
|
||||
return { el };
|
||||
}
|
||||
|
||||
function eventTitles(el: CardEl): string[] {
|
||||
return [...el.shadowRoot!.querySelectorAll(".cal-event-title")]
|
||||
.map((e) => e.textContent?.trim() || "");
|
||||
}
|
||||
|
||||
describe("calendar-card", () => {
|
||||
it("window chips re-bucket: a 20-days-out event survives +30d but not +7d", async () => {
|
||||
const { el } = await mount([
|
||||
task({ id: "near", name: "Near", days_until_due: 2, next_due: isoInDays(2), interval_days: 400 }),
|
||||
task({ id: "far", name: "Far", days_until_due: 20, next_due: isoInDays(20), interval_days: 400 }),
|
||||
]);
|
||||
|
||||
// Default +30d window shows both.
|
||||
expect(eventTitles(el).some((t2) => t2.includes("Near"))).to.be.true;
|
||||
expect(eventTitles(el).some((t2) => t2.includes("Far"))).to.be.true;
|
||||
|
||||
// Click the +7d chip → the 20-days-out event drops off.
|
||||
const chip = [...el.shadowRoot!.querySelectorAll<HTMLButtonElement>(".cal-window-chip")]
|
||||
.find((c) => c.textContent?.trim() === "+7d")!;
|
||||
chip.click();
|
||||
await el.updateComplete;
|
||||
expect(eventTitles(el).some((t2) => t2.includes("Near"))).to.be.true;
|
||||
expect(eventTitles(el).some((t2) => t2.includes("Far"))).to.be.false;
|
||||
});
|
||||
|
||||
it("projected recurrences carry the projected class; the real event does not", async () => {
|
||||
// 10-day interval inside a 30-day window → 1 real + projected occurrences.
|
||||
const { el } = await mount([
|
||||
task({ id: "rec", name: "Recurring", days_until_due: 3, next_due: isoInDays(3), interval_days: 10 }),
|
||||
]);
|
||||
|
||||
const real = [...el.shadowRoot!.querySelectorAll(".cal-event:not(.cal-event-projected)")];
|
||||
const projected = [...el.shadowRoot!.querySelectorAll(".cal-event.cal-event-projected")];
|
||||
expect(real.length).to.equal(1);
|
||||
expect(projected.length).to.be.greaterThan(0);
|
||||
});
|
||||
|
||||
it("clicking an event fires the ll-custom open-task payload", async () => {
|
||||
const { el } = await mount([
|
||||
task({ id: "t42", name: "Clicky", days_until_due: 2, next_due: isoInDays(2), interval_days: 400 }),
|
||||
]);
|
||||
|
||||
let detail: Record<string, unknown> | null = null;
|
||||
el.addEventListener("ll-custom", (e) => {
|
||||
detail = (e as CustomEvent<Record<string, unknown>>).detail;
|
||||
});
|
||||
|
||||
el.shadowRoot!.querySelector<HTMLElement>(".cal-event")!.click();
|
||||
expect(detail, "ll-custom fired").to.not.be.null;
|
||||
expect(detail!.type).to.equal("maintenance-supporter:open-task");
|
||||
expect(detail!.entry_id).to.equal("e1");
|
||||
expect(detail!.task_id).to.equal("t42");
|
||||
});
|
||||
});
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Component test for the Lovelace card task sort (forum thread /995556 #7,
|
||||
* reported by brunkj — v2.3.7).
|
||||
*
|
||||
* Pins:
|
||||
* - Tasks sort by status first (overdue, triggered, due_soon, ok)
|
||||
* - Within a status, soonest-due-first (the bug: previously kept WS/creation
|
||||
* order, so a task due in 3 days could sit above one due in 1 day)
|
||||
* - Tasks without a numeric due date sort last within their status
|
||||
*/
|
||||
|
||||
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, days: number | null) => ({
|
||||
id, name, status, days_until_due: days, type: "service",
|
||||
});
|
||||
const O = (entry_id: string, name: string, tasks: unknown[]) => ({
|
||||
entry_id, object: { id: entry_id, name }, tasks,
|
||||
});
|
||||
|
||||
// Creation order deliberately NOT in due-date order, to prove the sort
|
||||
// no longer falls back to insertion order for same-status ties.
|
||||
function mockObjects() {
|
||||
return [
|
||||
O("e1", "Aqua Pool", [T("t1", "Check chlorine", "due_soon", 3)]),
|
||||
O("e2", "Hot Tub", [T("t2", "Check pH", "due_soon", 1)]),
|
||||
O("e3", "Furnace", [
|
||||
T("t3", "Replace filter", "overdue", -2),
|
||||
T("t4", "Deep service", "overdue", -10),
|
||||
]),
|
||||
O("e4", "Garage", [T("t5", "Manual check", "due_soon", null)]),
|
||||
O("e5", "Lamp", [T("t6", "Dust", "ok", 40)]),
|
||||
];
|
||||
}
|
||||
|
||||
function mockHass() {
|
||||
return {
|
||||
language: "en",
|
||||
connection: {
|
||||
sendMessagePromise: async (msg: { type: string }) =>
|
||||
msg.type === "maintenance_supporter/objects"
|
||||
? { objects: mockObjects() }
|
||||
: { 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").length > 0,
|
||||
"task rows render",
|
||||
{ timeout: 2000 }
|
||||
);
|
||||
await el.updateComplete;
|
||||
return el;
|
||||
}
|
||||
|
||||
const names = (el: MaintenanceSupporterCard) =>
|
||||
[...el.shadowRoot!.querySelectorAll(".task-name")].map((n) => n.textContent?.trim() || "");
|
||||
|
||||
describe("maintenance-card sort (forum #7 — due-date tiebreaker)", () => {
|
||||
it("orders by status, then soonest-due first within a status", async () => {
|
||||
const el = await mount();
|
||||
expect(names(el)).to.deep.equal([
|
||||
"Deep service", // overdue -10 (most overdue first)
|
||||
"Replace filter", // overdue -2
|
||||
"Check pH", // due_soon 1
|
||||
"Check chlorine", // due_soon 3
|
||||
"Manual check", // due_soon null -> last within due_soon
|
||||
"Dust", // ok 40
|
||||
]);
|
||||
});
|
||||
|
||||
it("reported case: due-in-1-day sorts before due-in-3-days (same status)", async () => {
|
||||
const el = await mount({ filter_status: ["due_soon"] });
|
||||
const n = names(el);
|
||||
expect(n.indexOf("Check pH")).to.be.lessThan(n.indexOf("Check chlorine"));
|
||||
});
|
||||
});
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Behavioural tests for <maintenance-complete-dialog> (audit gap #3).
|
||||
*
|
||||
* The completion dialog is the money path of the whole product; until now it
|
||||
* was only covered by the lazy-load tripwire. These tests pin the exact
|
||||
* outgoing WS payload (notes / cost / duration / feedback / checklist_state /
|
||||
* photo_doc_id) and the error path (server refusal keeps the dialog open).
|
||||
*/
|
||||
|
||||
import { expect, fixture, html } from "@open-wc/testing";
|
||||
import "../components/complete-dialog.js";
|
||||
import type { MaintenanceCompleteDialog } from "../components/complete-dialog";
|
||||
import { createMockHass } from "./_test-utils.js";
|
||||
|
||||
type MountOpts = {
|
||||
checklist?: string[];
|
||||
adaptiveEnabled?: boolean;
|
||||
completeHandler?: () => unknown;
|
||||
};
|
||||
|
||||
async function mount(opts: MountOpts = {}) {
|
||||
const { hass, sent } = createMockHass({
|
||||
handlers: {
|
||||
"maintenance_supporter/task/complete":
|
||||
opts.completeHandler ?? (() => ({ success: true })),
|
||||
},
|
||||
});
|
||||
const el = await fixture<MaintenanceCompleteDialog>(html`
|
||||
<maintenance-complete-dialog
|
||||
.hass=${hass}
|
||||
.entryId=${"entry1"}
|
||||
.taskId=${"task1"}
|
||||
.taskName=${"Filter Wechsel"}
|
||||
.lang=${"en"}
|
||||
.checklist=${opts.checklist ?? []}
|
||||
.adaptiveEnabled=${opts.adaptiveEnabled ?? false}
|
||||
></maintenance-complete-dialog>
|
||||
`);
|
||||
el.open();
|
||||
await el.updateComplete;
|
||||
return { el, sent };
|
||||
}
|
||||
|
||||
function setInput(el: MaintenanceCompleteDialog, index: number, value: string) {
|
||||
const input = [...el.shadowRoot!.querySelectorAll<HTMLInputElement>(".field-input")][index];
|
||||
input.value = value;
|
||||
input.dispatchEvent(new Event("input"));
|
||||
}
|
||||
|
||||
function clickComplete(el: MaintenanceCompleteDialog) {
|
||||
const buttons = [...el.shadowRoot!.querySelectorAll(".dialog-actions ha-button")];
|
||||
(buttons[buttons.length - 1] as HTMLElement).click();
|
||||
}
|
||||
|
||||
describe("complete-dialog", () => {
|
||||
it("submits notes, cost, duration, feedback and checklist_state in the payload", async () => {
|
||||
const { el, sent } = await mount({
|
||||
checklist: ["Step A", "Step B"],
|
||||
adaptiveEnabled: true,
|
||||
});
|
||||
|
||||
setInput(el, 0, "oil changed");
|
||||
setInput(el, 1, "12.5");
|
||||
setInput(el, 2, "30");
|
||||
// Tick the second checklist step (click the checkbox; the event bubbles
|
||||
// to the row's toggle handler exactly once).
|
||||
const boxes = [...el.shadowRoot!.querySelectorAll<HTMLInputElement>(".checklist-item input")];
|
||||
boxes[1].click();
|
||||
// Pick the "not needed" feedback option.
|
||||
const fb = [...el.shadowRoot!.querySelectorAll<HTMLButtonElement>(".feedback-btn")];
|
||||
fb[1].click();
|
||||
await el.updateComplete;
|
||||
|
||||
clickComplete(el);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
const msg = sent.find((m) => m.type === "maintenance_supporter/task/complete")!;
|
||||
expect(msg, "task/complete sent").to.exist;
|
||||
expect(msg.entry_id).to.equal("entry1");
|
||||
expect(msg.task_id).to.equal("task1");
|
||||
expect(msg.notes).to.equal("oil changed");
|
||||
expect(msg.cost).to.equal(12.5);
|
||||
expect(msg.duration).to.equal(30);
|
||||
expect(msg.feedback).to.equal("not_needed");
|
||||
expect(msg.checklist_state).to.deep.equal({ "1": true });
|
||||
// Dialog closed on success.
|
||||
await el.updateComplete;
|
||||
expect(el.shadowRoot!.querySelector("ha-dialog")).to.be.null;
|
||||
});
|
||||
|
||||
it("omits optional fields that were left empty", async () => {
|
||||
const { el, sent } = await mount();
|
||||
clickComplete(el);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
const msg = sent.find((m) => m.type === "maintenance_supporter/task/complete")!;
|
||||
expect(msg).to.exist;
|
||||
expect("notes" in msg).to.be.false;
|
||||
expect("cost" in msg).to.be.false;
|
||||
expect("duration" in msg).to.be.false;
|
||||
expect("feedback" in msg).to.be.false;
|
||||
expect("checklist_state" in msg).to.be.false;
|
||||
expect("photo_doc_id" in msg).to.be.false;
|
||||
});
|
||||
|
||||
it("attaches an uploaded photo as photo_doc_id", async () => {
|
||||
const { el, sent } = await mount();
|
||||
|
||||
// Stub the document-upload endpoint the photo picker posts to.
|
||||
const realFetch = window.fetch;
|
||||
const uploads: RequestInit[] = [];
|
||||
window.fetch = (async (_url: RequestInfo | URL, init?: RequestInit) => {
|
||||
uploads.push(init!);
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ id: "doc-photo-1", deduped: false }),
|
||||
} as Response;
|
||||
}) as typeof window.fetch;
|
||||
|
||||
try {
|
||||
const fileInput = el.shadowRoot!.querySelector<HTMLInputElement>(
|
||||
'.photo-pick input[type="file"]',
|
||||
)!;
|
||||
const dt = new DataTransfer();
|
||||
dt.items.add(new File(["fake-png"], "done.png", { type: "image/png" }));
|
||||
fileInput.files = dt.files;
|
||||
fileInput.dispatchEvent(new Event("change"));
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
await el.updateComplete;
|
||||
|
||||
// Preview replaces the picker once the upload returns an id.
|
||||
expect(el.shadowRoot!.querySelector(".photo-preview img")).to.exist;
|
||||
expect(uploads.length).to.equal(1);
|
||||
const form = uploads[0].body as FormData;
|
||||
expect(form.get("entry_id")).to.equal("entry1");
|
||||
expect(form.get("tags")).to.equal("photo");
|
||||
|
||||
clickComplete(el);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
const msg = sent.find((m) => m.type === "maintenance_supporter/task/complete")!;
|
||||
expect(msg.photo_doc_id).to.equal("doc-photo-1");
|
||||
} finally {
|
||||
window.fetch = realFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("shows the server error and stays open on refusal (e.g. too_early)", async () => {
|
||||
const { el, sent } = await mount({
|
||||
completeHandler: () => {
|
||||
throw { code: "too_early", message: "Task can only be completed closer to its due date" };
|
||||
},
|
||||
});
|
||||
let completedEvent = false;
|
||||
el.addEventListener("task-completed", () => (completedEvent = true));
|
||||
|
||||
clickComplete(el);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
await el.updateComplete;
|
||||
|
||||
expect(sent.find((m) => m.type === "maintenance_supporter/task/complete")).to.exist;
|
||||
// Dialog stays open with a visible error; no completion event fired.
|
||||
expect(el.shadowRoot!.querySelector("ha-dialog")).to.exist;
|
||||
const err = el.shadowRoot!.querySelector(".error");
|
||||
expect(err, "error banner rendered").to.exist;
|
||||
expect((err!.textContent || "").length).to.be.greaterThan(0);
|
||||
expect(completedEvent).to.be.false;
|
||||
});
|
||||
});
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Tripwire: NO dialog mounted via dialog-mount may use HA's lazy-loaded
|
||||
* elements (`ha-textfield`, `ha-textarea`, `ha-entity-picker`) in its
|
||||
* shadow DOM. These elements aren't reliably registered in the panel-
|
||||
* custom or Lovelace contexts, render as HTMLUnknownElement with
|
||||
* offsetHeight=0, and look like an empty form to the user.
|
||||
*
|
||||
* History:
|
||||
* - #50: complete-dialog notes/cost/duration invisible
|
||||
* → Fix: native <input> with field-input class
|
||||
* - #50 follow-up: task-dialog target-entity invisible
|
||||
* → Fix: <ha-form> with entity selector schema
|
||||
* - #46 follow-up: object-dialog name/manufacturer/model/serial/url/notes
|
||||
* invisible (only ha-area-picker rendered → user could "only change
|
||||
* the area")
|
||||
* → Fix: <ms-textfield> wrapper + native <textarea> for notes
|
||||
*
|
||||
* Allowed elements (verified to lazy-load reliably or wrappable):
|
||||
* - ha-area-picker, ha-form, ha-service-picker, ha-icon, ha-svg-icon,
|
||||
* ha-button, ha-dialog, ha-icon-button, ha-switch, mwc-button,
|
||||
* mwc-icon-button, ha-list-item, mwc-list-item
|
||||
*
|
||||
* If you need a textfield, use <ms-textfield> (components/ms-textfield.ts).
|
||||
* If you need an entity picker, use <ha-form> with
|
||||
* `selector: { entity: {...} }` schema — ha-form lazy-loads its child
|
||||
* pickers internally + IS itself reliably registered.
|
||||
*
|
||||
* This test mounts each dialog component and walks its shadow DOM
|
||||
* looking for any banned tag. If you add a new dialog, import + mount
|
||||
* it here too.
|
||||
*/
|
||||
|
||||
import { expect, fixture, html } from "@open-wc/testing";
|
||||
import "../components/object-dialog.js";
|
||||
import "../components/task-dialog.js";
|
||||
import "../components/group-dialog.js";
|
||||
import "../components/complete-dialog.js";
|
||||
import "../components/history-edit-dialog.js";
|
||||
import "../components/qr-dialog.js";
|
||||
import "../components/seasonal-overrides-dialog.js";
|
||||
import "../components/object-quick-actions-dialog.js";
|
||||
import "../components/task-quick-actions-dialog.js";
|
||||
import "../components/confirm-dialog.js";
|
||||
import { createMockHass } from "./_test-utils.js";
|
||||
|
||||
const BANNED_TAGS = ["ha-textfield", "ha-textarea", "ha-entity-picker"];
|
||||
|
||||
function findBannedTags(root: ShadowRoot | Element): string[] {
|
||||
const banned: string[] = [];
|
||||
for (const tag of BANNED_TAGS) {
|
||||
const matches = root.querySelectorAll(tag);
|
||||
matches.forEach((el) => {
|
||||
banned.push(`<${tag}> at depth ${depthOf(el, root)} (label="${el.getAttribute("label") || ""}")`);
|
||||
});
|
||||
}
|
||||
return banned;
|
||||
}
|
||||
|
||||
function depthOf(el: Element, root: ShadowRoot | Element): number {
|
||||
let depth = 0;
|
||||
let cur: ParentNode | null = el.parentNode;
|
||||
while (cur && cur !== root) {
|
||||
depth++;
|
||||
cur = cur.parentNode;
|
||||
}
|
||||
return depth;
|
||||
}
|
||||
|
||||
describe("dialog tripwire: no lazy-loaded HA elements", () => {
|
||||
it("object-dialog", async () => {
|
||||
const { hass } = createMockHass();
|
||||
const el = await fixture<HTMLElement & { hass: unknown; openEdit: (e: string, o: unknown) => void }>(
|
||||
html`<maintenance-object-dialog .hass=${hass}></maintenance-object-dialog>`,
|
||||
);
|
||||
el.openEdit("entry_x", {
|
||||
id: "obj_1", name: "Test", area_id: "garage",
|
||||
manufacturer: "ACME", model: "X1", serial_number: "SN1",
|
||||
installation_date: "2025-01-01", documentation_url: "https://x.test/",
|
||||
notes: "test notes",
|
||||
});
|
||||
await (el as HTMLElement & { updateComplete: Promise<void> }).updateComplete;
|
||||
const banned = findBannedTags(el.shadowRoot!);
|
||||
expect(banned, `object-dialog has banned lazy-load elements: ${banned.join(", ")}`)
|
||||
.to.have.lengthOf(0);
|
||||
});
|
||||
|
||||
it("task-dialog with all feature flags on (worst case = most fields rendered)", async () => {
|
||||
const { hass } = createMockHass({
|
||||
services: { button: { press: {} } },
|
||||
});
|
||||
const el = await fixture<HTMLElement & {
|
||||
hass: unknown; openEdit: (e: string, t: unknown) => Promise<void>;
|
||||
checklistsEnabled: boolean; scheduleTimeEnabled: boolean; completionActionsEnabled: boolean;
|
||||
}>(
|
||||
html`<maintenance-task-dialog
|
||||
.hass=${hass}
|
||||
.checklistsEnabled=${true}
|
||||
.scheduleTimeEnabled=${true}
|
||||
.completionActionsEnabled=${true}
|
||||
></maintenance-task-dialog>`,
|
||||
);
|
||||
await el.openEdit("entry_x", {
|
||||
id: "t1", name: "Test Task", type: "custom", schedule_type: "time_based",
|
||||
interval_days: 30, warning_days: 7, enabled: true,
|
||||
checklist: ["step 1"],
|
||||
schedule_time: "09:00",
|
||||
on_complete_action: { service: "button.press", target: { entity_id: "button.x" } },
|
||||
});
|
||||
await (el as HTMLElement & { updateComplete: Promise<void> }).updateComplete;
|
||||
// Expand <details> sections so action UI is in the DOM
|
||||
el.shadowRoot!.querySelectorAll<HTMLDetailsElement>("details").forEach((d) => { d.open = true; });
|
||||
await (el as HTMLElement & { updateComplete: Promise<void> }).updateComplete;
|
||||
const banned = findBannedTags(el.shadowRoot!);
|
||||
expect(banned, `task-dialog has banned lazy-load elements: ${banned.join(", ")}`)
|
||||
.to.have.lengthOf(0);
|
||||
});
|
||||
|
||||
it("group-dialog", async () => {
|
||||
const { hass } = createMockHass({
|
||||
handlers: { "maintenance_supporter/objects": () => ({ objects: [] }) },
|
||||
});
|
||||
const el = await fixture<HTMLElement & { hass: unknown; openCreate: () => void }>(
|
||||
html`<maintenance-group-dialog .hass=${hass}></maintenance-group-dialog>`,
|
||||
);
|
||||
el.openCreate();
|
||||
await (el as HTMLElement & { updateComplete: Promise<void> }).updateComplete;
|
||||
const banned = findBannedTags(el.shadowRoot!);
|
||||
expect(banned, `group-dialog has banned lazy-load elements: ${banned.join(", ")}`)
|
||||
.to.have.lengthOf(0);
|
||||
});
|
||||
|
||||
it("complete-dialog", async () => {
|
||||
const { hass } = createMockHass();
|
||||
const el = await fixture<HTMLElement & {
|
||||
hass: unknown; entryId: string; taskId: string; taskName: string; open: () => void;
|
||||
}>(
|
||||
html`<maintenance-complete-dialog .hass=${hass}></maintenance-complete-dialog>`,
|
||||
);
|
||||
el.entryId = "entry_x"; el.taskId = "t1"; el.taskName = "Test";
|
||||
el.open();
|
||||
await (el as HTMLElement & { updateComplete: Promise<void> }).updateComplete;
|
||||
const banned = findBannedTags(el.shadowRoot!);
|
||||
expect(banned, `complete-dialog has banned lazy-load elements: ${banned.join(", ")}`)
|
||||
.to.have.lengthOf(0);
|
||||
});
|
||||
|
||||
it("history-edit-dialog", async () => {
|
||||
const { hass } = createMockHass();
|
||||
const el = await fixture<HTMLElement & {
|
||||
hass: unknown; openEdit: (d: unknown) => void;
|
||||
}>(
|
||||
html`<maintenance-history-edit-dialog .hass=${hass}></maintenance-history-edit-dialog>`,
|
||||
);
|
||||
el.openEdit({
|
||||
entry_id: "e", task_id: "t", original_timestamp: "2025-01-01T00:00:00",
|
||||
type: "completed", timestamp: "2025-01-01T00:00:00",
|
||||
notes: null, cost: null, duration: null, completed_by: null,
|
||||
});
|
||||
await (el as HTMLElement & { updateComplete: Promise<void> }).updateComplete;
|
||||
const banned = findBannedTags(el.shadowRoot!);
|
||||
expect(banned, `history-edit-dialog has banned lazy-load elements: ${banned.join(", ")}`)
|
||||
.to.have.lengthOf(0);
|
||||
});
|
||||
});
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Lit component tests for <maintenance-documents-section>.
|
||||
*
|
||||
* Covers the WS-driven paths (list render, add-link, delete) and the write
|
||||
* gate. File upload goes through fetch() to the authenticated view and is
|
||||
* verified live (live_docs.py), not here.
|
||||
*/
|
||||
|
||||
import { expect, fixture, html } from "@open-wc/testing";
|
||||
import "../components/documents-section.js";
|
||||
import type { MaintenanceDocumentsSection } from "../components/documents-section";
|
||||
import { createMockHass } from "./_test-utils.js";
|
||||
|
||||
const FILE_DOC = {
|
||||
id: "d1", kind: "file", title: "Manual", filename: "m.pdf",
|
||||
mime: "application/pdf", size: 2048, tags: ["manual"], added_at: "2026-01-01T00:00:00",
|
||||
};
|
||||
const LINK_DOC = {
|
||||
id: "d2", kind: "weblink", title: "Online", url: "https://example.com/x",
|
||||
tags: [], added_at: "2026-01-01T00:00:00",
|
||||
};
|
||||
|
||||
async function mount(canWrite = true, docs: unknown[] = [FILE_DOC, LINK_DOC]) {
|
||||
const { hass, sent } = createMockHass({
|
||||
handlers: {
|
||||
"maintenance_supporter/documents/list": () => ({ documents: docs }),
|
||||
"maintenance_supporter/documents/add_link": () => ({ id: "new", kind: "weblink", url: "https://x", tags: [] }),
|
||||
"maintenance_supporter/documents/delete": () => ({ success: true, bytes_freed: 0 }),
|
||||
},
|
||||
});
|
||||
const el = await fixture<MaintenanceDocumentsSection>(html`
|
||||
<maintenance-documents-section .hass=${hass} .entryId=${"e1"} .canWrite=${canWrite}></maintenance-documents-section>
|
||||
`);
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
await el.updateComplete;
|
||||
return { el, sent };
|
||||
}
|
||||
|
||||
describe("documents-section", () => {
|
||||
it("lists documents with a count", async () => {
|
||||
const { el } = await mount();
|
||||
const rows = el.shadowRoot!.querySelectorAll(".doc-row");
|
||||
expect(rows.length).to.equal(2);
|
||||
expect(el.shadowRoot!.querySelector("h3")!.textContent).to.contain("2");
|
||||
});
|
||||
|
||||
it("offers all six categories when writable", async () => {
|
||||
const { el } = await mount(true);
|
||||
const opts = el.shadowRoot!.querySelectorAll(".cat-select option");
|
||||
expect(opts.length).to.equal(6);
|
||||
});
|
||||
|
||||
it("adds a web-link via WS", async () => {
|
||||
const { el, sent } = await mount();
|
||||
const toggle = [...el.shadowRoot!.querySelectorAll("button")].find((b) =>
|
||||
/link/i.test(b.textContent || ""),
|
||||
);
|
||||
toggle!.click();
|
||||
await el.updateComplete;
|
||||
|
||||
const urlInput = el.shadowRoot!.querySelector<HTMLInputElement>('input[type="url"]')!;
|
||||
urlInput.value = "https://example.com/manual.pdf";
|
||||
urlInput.dispatchEvent(new Event("input"));
|
||||
await el.updateComplete;
|
||||
|
||||
el.shadowRoot!.querySelector<HTMLButtonElement>(".link-form .btn.primary")!.click();
|
||||
await el.updateComplete;
|
||||
|
||||
const msg = sent.find((m) => m.type === "maintenance_supporter/documents/add_link");
|
||||
expect(msg, "add_link WS sent").to.exist;
|
||||
expect(msg!.url).to.equal("https://example.com/manual.pdf");
|
||||
});
|
||||
|
||||
it("deletes a document via WS after confirmation", async () => {
|
||||
const orig = window.confirm;
|
||||
window.confirm = () => true;
|
||||
try {
|
||||
const { el, sent } = await mount();
|
||||
el.shadowRoot!.querySelector<HTMLButtonElement>(".icon-btn.danger")!.click();
|
||||
await el.updateComplete;
|
||||
const msg = sent.find((m) => m.type === "maintenance_supporter/documents/delete");
|
||||
expect(msg, "delete WS sent").to.exist;
|
||||
expect(msg!.doc_id).to.equal("d1");
|
||||
} finally {
|
||||
window.confirm = orig;
|
||||
}
|
||||
});
|
||||
|
||||
it("hides write controls when not writable", async () => {
|
||||
const { el } = await mount(false);
|
||||
expect(el.shadowRoot!.querySelector(".cat-select"), "no category select").to.not.exist;
|
||||
expect(el.shadowRoot!.querySelector(".icon-btn.danger"), "no delete button").to.not.exist;
|
||||
// read-only still lists documents and offers open/download
|
||||
expect(el.shadowRoot!.querySelectorAll(".doc-row").length).to.equal(2);
|
||||
});
|
||||
|
||||
it("shows an image thumbnail and opens it in a lightbox", async () => {
|
||||
const IMG = {
|
||||
id: "img1", kind: "file", title: "Typenschild", filename: "p.jpg",
|
||||
mime: "image/jpeg", size: 100, tags: ["photo"], added_at: "2026-01-01T00:00:00",
|
||||
};
|
||||
const { hass } = createMockHass({
|
||||
handlers: {
|
||||
"maintenance_supporter/documents/list": () => ({ documents: [IMG] }),
|
||||
"auth/sign_path": () => ({ path: "/api/maintenance_supporter/document/img1?authSig=x" }),
|
||||
},
|
||||
});
|
||||
const el = await fixture<MaintenanceDocumentsSection>(html`
|
||||
<maintenance-documents-section .hass=${hass} .entryId=${"e1"} .canWrite=${true}></maintenance-documents-section>
|
||||
`);
|
||||
await new Promise((r) => setTimeout(r, 40));
|
||||
await el.updateComplete;
|
||||
|
||||
const thumb = el.shadowRoot!.querySelector<HTMLImageElement>(".doc-thumb");
|
||||
expect(thumb, "thumbnail rendered").to.exist;
|
||||
expect(thumb!.src).to.contain("authSig");
|
||||
|
||||
thumb!.click();
|
||||
await el.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
await el.updateComplete;
|
||||
expect(el.shadowRoot!.querySelector(".lightbox"), "lightbox open").to.exist;
|
||||
});
|
||||
|
||||
it("opens a document by clicking its title row, not just the icons", async () => {
|
||||
const IMG = {
|
||||
id: "img1", kind: "file", title: "Typenschild", filename: "p.jpg",
|
||||
mime: "image/jpeg", size: 100, tags: ["photo"], added_at: "2026-01-01T00:00:00",
|
||||
};
|
||||
const { hass } = createMockHass({
|
||||
handlers: {
|
||||
"maintenance_supporter/documents/list": () => ({ documents: [IMG] }),
|
||||
"auth/sign_path": () => ({ path: "/api/maintenance_supporter/document/img1?authSig=x" }),
|
||||
},
|
||||
});
|
||||
const el = await fixture<MaintenanceDocumentsSection>(html`
|
||||
<maintenance-documents-section .hass=${hass} .entryId=${"e1"} .canWrite=${true}></maintenance-documents-section>
|
||||
`);
|
||||
await new Promise((r) => setTimeout(r, 40));
|
||||
await el.updateComplete;
|
||||
|
||||
const info = el.shadowRoot!.querySelector<HTMLElement>(".doc-info");
|
||||
expect(info, "title row present").to.exist;
|
||||
expect(info!.getAttribute("role"), "title row is a button").to.equal("button");
|
||||
info!.click();
|
||||
await el.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
await el.updateComplete;
|
||||
expect(el.shadowRoot!.querySelector(".lightbox"), "clicking the title opens the preview").to.exist;
|
||||
});
|
||||
|
||||
it("edits a document's title/category via WS", async () => {
|
||||
const { el, sent } = await mount();
|
||||
const editBtn = [...el.shadowRoot!.querySelectorAll(".doc-row-actions .icon-btn")].find(
|
||||
(b) => b.querySelector('ha-icon[icon="mdi:pencil"]'),
|
||||
) as HTMLButtonElement;
|
||||
editBtn.click();
|
||||
await el.updateComplete;
|
||||
|
||||
const title = el.shadowRoot!.querySelector<HTMLInputElement>(".edit-title");
|
||||
expect(title, "edit form open").to.exist;
|
||||
title!.value = "Renamed manual";
|
||||
title!.dispatchEvent(new Event("input"));
|
||||
await el.updateComplete;
|
||||
|
||||
const saveBtn = [...el.shadowRoot!.querySelectorAll(".doc-row.editing .icon-btn")].find(
|
||||
(b) => b.querySelector('ha-icon[icon="mdi:check"]'),
|
||||
) as HTMLButtonElement;
|
||||
saveBtn.click();
|
||||
await el.updateComplete;
|
||||
|
||||
const msg = sent.find((m) => m.type === "maintenance_supporter/documents/update");
|
||||
expect(msg, "update WS sent").to.exist;
|
||||
expect(msg!.title).to.equal("Renamed manual");
|
||||
expect(msg!.doc_id).to.equal("d1");
|
||||
});
|
||||
|
||||
it("uploads multiple files and honors the camera category override", async () => {
|
||||
const { el } = await mount(true, [FILE_DOC]);
|
||||
(el.hass as unknown as { auth: unknown }).auth = { data: { access_token: "tok" } };
|
||||
const tags: string[] = [];
|
||||
const origFetch = window.fetch;
|
||||
window.fetch = (async (_url: string, opts: { body: FormData }) => {
|
||||
tags.push(opts.body.get("tags") as string);
|
||||
return new Response(JSON.stringify({ id: "n", deduped: false }), { status: 200 });
|
||||
}) as unknown as typeof window.fetch;
|
||||
const up = (el as unknown as { _uploadFiles: (f: File[], c?: string) => Promise<void> })._uploadFiles;
|
||||
try {
|
||||
const f1 = new File(["a"], "a.pdf", { type: "application/pdf" });
|
||||
const f2 = new File(["b"], "b.pdf", { type: "application/pdf" });
|
||||
await up.call(el, [f1, f2]); // one POST per file, default category
|
||||
expect(tags).to.deep.equal(["manual", "manual"]);
|
||||
tags.length = 0;
|
||||
await up.call(el, [f1], "photo"); // camera override
|
||||
expect(tags).to.deep.equal(["photo"]);
|
||||
} finally {
|
||||
window.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("opens the file picker via keyboard on the upload label (a11y)", async () => {
|
||||
const { el } = await mount();
|
||||
const label = [...el.shadowRoot!.querySelectorAll("label.btn")].find(
|
||||
(l) => l.querySelector('input[type="file"][multiple]'),
|
||||
) as HTMLElement;
|
||||
expect(label, "upload label is focusable").to.exist;
|
||||
expect(label.getAttribute("tabindex")).to.equal("0");
|
||||
expect(label.getAttribute("role")).to.equal("button");
|
||||
|
||||
const input = label.querySelector<HTMLInputElement>('input[type="file"]')!;
|
||||
let clicked = false;
|
||||
input.click = () => { clicked = true; };
|
||||
label.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||
expect(clicked, "Enter triggers the hidden file input").to.be.true;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* downloadTextFile must produce a Companion-app-safe download: a `target=_blank`
|
||||
* anchor with the right `download` name, appended to the DOM, and the blob URL
|
||||
* must NOT be revoked synchronously (an immediate revoke cancels the async
|
||||
* download in the HA Companion app's WebView).
|
||||
*/
|
||||
import { expect } from "@open-wc/testing";
|
||||
import { downloadTextFile } from "../helpers/download";
|
||||
|
||||
describe("downloadTextFile — Companion-app safe download", () => {
|
||||
let created: number;
|
||||
let revoked: number;
|
||||
let captured: { download: string; target: string; href: string } | null;
|
||||
let origCreate: typeof URL.createObjectURL;
|
||||
let origRevoke: typeof URL.revokeObjectURL;
|
||||
let origAppend: typeof document.body.appendChild;
|
||||
|
||||
beforeEach(() => {
|
||||
created = 0;
|
||||
revoked = 0;
|
||||
captured = null;
|
||||
origCreate = URL.createObjectURL;
|
||||
origRevoke = URL.revokeObjectURL;
|
||||
origAppend = document.body.appendChild.bind(document.body);
|
||||
URL.createObjectURL = () => { created++; return "blob:fake-url"; };
|
||||
URL.revokeObjectURL = () => { revoked++; };
|
||||
document.body.appendChild = ((node: Node) => {
|
||||
if (node instanceof HTMLAnchorElement) {
|
||||
captured = { download: node.download, target: node.target, href: node.href };
|
||||
node.dispatchEvent = () => true; // suppress real navigation in the test
|
||||
}
|
||||
return origAppend(node as never);
|
||||
}) as typeof document.body.appendChild;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
URL.createObjectURL = origCreate;
|
||||
URL.revokeObjectURL = origRevoke;
|
||||
document.body.appendChild = origAppend;
|
||||
});
|
||||
|
||||
it("uses a target=_blank anchor with the given filename, appended to the DOM", () => {
|
||||
downloadTextFile("a,b\n1,2", "objects.csv", "text/csv");
|
||||
expect(created).to.equal(1);
|
||||
expect(captured).to.not.equal(null);
|
||||
expect(captured!.download).to.equal("objects.csv");
|
||||
expect(captured!.target).to.equal("_blank");
|
||||
expect(captured!.href).to.equal("blob:fake-url");
|
||||
});
|
||||
|
||||
it("does NOT revoke the blob URL synchronously", () => {
|
||||
downloadTextFile("x", "x.csv", "text/csv");
|
||||
expect(revoked).to.equal(0);
|
||||
});
|
||||
|
||||
it("cleans up the temporary anchor", () => {
|
||||
const before = document.body.querySelectorAll("a").length;
|
||||
downloadTextFile("x", "x.csv", "text/csv");
|
||||
expect(document.body.querySelectorAll("a").length).to.equal(before);
|
||||
});
|
||||
});
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/** Tests for the chart outlier filter (IQR fence). */
|
||||
|
||||
import { expect } from "@open-wc/testing";
|
||||
import { filterOutliers } from "../renderers/sparkline.js";
|
||||
import type { ChartPoint } from "../components/trigger-chart";
|
||||
|
||||
const pts = (vals: number[]): ChartPoint[] =>
|
||||
vals.map((val, i) => ({ ts: i * 1000, val }));
|
||||
|
||||
describe("filterOutliers", () => {
|
||||
it("drops a wild glitch reading (pressure 1.5–3 → 100)", () => {
|
||||
const input = pts([1.6, 1.8, 2.0, 2.2, 1.9, 2.1, 100, 1.7, 2.3, 1.5]);
|
||||
const out = filterOutliers(input);
|
||||
expect(out.map((p) => p.val)).to.not.include(100);
|
||||
expect(out.length).to.equal(input.length - 1);
|
||||
});
|
||||
|
||||
it("keeps a normal spread untouched", () => {
|
||||
const input = pts([1.6, 1.8, 2.0, 2.2, 1.9, 2.1, 1.7, 2.3, 1.5, 2.0]);
|
||||
expect(filterOutliers(input).length).to.equal(input.length);
|
||||
});
|
||||
|
||||
it("no-ops on short series (< 4 points)", () => {
|
||||
const input = pts([1, 100, 2]);
|
||||
expect(filterOutliers(input).length).to.equal(3);
|
||||
});
|
||||
|
||||
it("never strips below a drawable series", () => {
|
||||
// Two extreme values, everything else identical → IQR fence could nuke both
|
||||
// ends, but we must keep at least 2 points.
|
||||
const input = pts([5, 5, 5, 5, 5, 5, 999, -999]);
|
||||
expect(filterOutliers(input).length).to.be.greaterThan(1);
|
||||
});
|
||||
|
||||
it("returns the series unchanged when all values are identical (IQR 0)", () => {
|
||||
const input = pts([3, 3, 3, 3, 3]);
|
||||
expect(filterOutliers(input).length).to.equal(5);
|
||||
});
|
||||
});
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
/** Unit tests for formatRecurrence — the single recurrence label for every
|
||||
* schedule kind (Phase 4 calendar kinds). Weekday names come from Intl. */
|
||||
|
||||
import { expect } from "@open-wc/testing";
|
||||
import { formatRecurrence, setLocale } from "../styles";
|
||||
import de from "../locales/de.json";
|
||||
|
||||
describe("formatRecurrence", () => {
|
||||
// German strings are runtime-loaded (not bundled); seed the real table so the
|
||||
// localized-ordinal case exercises actual German, not the EN fallback.
|
||||
before(() => setLocale("de", de as Record<string, string>));
|
||||
|
||||
it("interval → '6 Months'", () => {
|
||||
expect(formatRecurrence({ schedule: { kind: "interval", every: 6, unit: "months" } }, "en")).to.equal("6 Months");
|
||||
});
|
||||
it("nth_weekday → '1st Saturday'", () => {
|
||||
expect(formatRecurrence({ schedule: { kind: "nth_weekday", nth: 1, weekday: 5 } }, "en")).to.equal("1st Saturday");
|
||||
});
|
||||
it("nth_weekday last → 'Last Saturday'", () => {
|
||||
expect(formatRecurrence({ schedule: { kind: "nth_weekday", nth: -1, weekday: 5 } }, "en")).to.equal("Last Saturday");
|
||||
});
|
||||
it("weekdays → 'Mon & Thu'", () => {
|
||||
expect(formatRecurrence({ schedule: { kind: "weekdays", weekdays: [0, 3] } }, "en")).to.equal("Mon & Thu");
|
||||
});
|
||||
it("day_of_month → 'Day 15'", () => {
|
||||
expect(formatRecurrence({ schedule: { kind: "day_of_month", day: 15 } }, "en")).to.equal("Day 15");
|
||||
});
|
||||
it("(#83) day -1 → 'Last day of month'", () => {
|
||||
expect(formatRecurrence({ schedule: { kind: "day_of_month", day: -1 } }, "en"))
|
||||
.to.equal("Last day of month");
|
||||
});
|
||||
it("(#83) day -1 + business → 'Last business day'", () => {
|
||||
expect(formatRecurrence({ schedule: { kind: "day_of_month", day: -1, business: true } }, "en"))
|
||||
.to.equal("Last business day");
|
||||
});
|
||||
it("(#83) negative offset renders as a −Nd suffix", () => {
|
||||
expect(formatRecurrence(
|
||||
{ schedule: { kind: "day_of_month", day: -1, business: true, offset: -2 } }, "en",
|
||||
)).to.equal("Last business day −2d");
|
||||
});
|
||||
it("(#83) positive offset on nth_weekday", () => {
|
||||
expect(formatRecurrence(
|
||||
{ schedule: { kind: "nth_weekday", nth: 1, weekday: 5, offset: 2 } }, "en",
|
||||
)).to.equal("1st Saturday +2d");
|
||||
});
|
||||
it("manual → 'Manual'", () => {
|
||||
expect(formatRecurrence({ schedule: { kind: "manual" } }, "en")).to.equal("Manual");
|
||||
});
|
||||
it("one_time → due date", () => {
|
||||
expect(formatRecurrence({ schedule: { kind: "one_time" }, due_date: "2026-09-01" }, "en")).to.contain("2026");
|
||||
});
|
||||
it("legacy flat interval (no nested schedule) → '30 Days'", () => {
|
||||
expect(formatRecurrence({ interval_days: 30, interval_unit: "days", schedule_type: "time_based" }, "en")).to.equal("30 Days");
|
||||
});
|
||||
it("German nth_weekday → '1. Samstag'", () => {
|
||||
expect(formatRecurrence({ schedule: { kind: "nth_weekday", nth: 1, weekday: 5 } }, "de")).to.equal("1. Samstag");
|
||||
});
|
||||
it("empty weekdays → em dash", () => {
|
||||
expect(formatRecurrence({ schedule: { kind: "weekdays", weekdays: [] } }, "en")).to.equal("—");
|
||||
});
|
||||
it("sensor_based (no schedule) → 'Sensor-based', not em dash", () => {
|
||||
expect(
|
||||
formatRecurrence({ schedule_type: "sensor_based", interval_days: null }, "en"),
|
||||
).to.equal("Sensor-based");
|
||||
});
|
||||
});
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Component tests for the group-dialog task list (#40 sort fix, v1.0.53).
|
||||
*
|
||||
* Pins:
|
||||
* - Object sections render alphabetically (not in `.objects` array order)
|
||||
* - Tasks within each object render alphabetically
|
||||
* - Toggling a checkbox updates the internal Set with the right
|
||||
* "entry_id:task_id" composite key
|
||||
*/
|
||||
|
||||
import { expect, fixture, html } from "@open-wc/testing";
|
||||
import "../components/group-dialog.js";
|
||||
import type { MaintenanceGroupDialog } from "../components/group-dialog";
|
||||
|
||||
function mockObjects() {
|
||||
// Deliberately given in non-alphabetical creation order.
|
||||
return [
|
||||
{
|
||||
entry_id: "e3",
|
||||
object: { id: "o3", name: "Zenith Compressor" },
|
||||
tasks: [
|
||||
{ id: "t31", name: "Tighten bolts" },
|
||||
{ id: "t32", name: "Air filter swap" },
|
||||
],
|
||||
},
|
||||
{
|
||||
entry_id: "e1",
|
||||
object: { id: "o1", name: "Aqua Pool" },
|
||||
tasks: [
|
||||
{ id: "t11", name: "pH check" },
|
||||
{ id: "t12", name: "Brush walls" },
|
||||
{ id: "t13", name: "Add chlorine" },
|
||||
],
|
||||
},
|
||||
{
|
||||
entry_id: "e2",
|
||||
object: { id: "o2", name: "Mid Garage" },
|
||||
tasks: [
|
||||
{ id: "t21", name: "Sweep floor" },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async function mount() {
|
||||
const hass = { language: "en", connection: { sendMessagePromise: async () => ({}) } };
|
||||
const el = await fixture<MaintenanceGroupDialog>(html`
|
||||
<maintenance-group-dialog .hass=${hass} .objects=${mockObjects()}></maintenance-group-dialog>
|
||||
`);
|
||||
el.openCreate();
|
||||
await el.updateComplete;
|
||||
return el;
|
||||
}
|
||||
|
||||
describe("group-dialog task list (#40 alphabetical sort)", () => {
|
||||
it("renders object sections alphabetically by object name", async () => {
|
||||
const el = await mount();
|
||||
const sections = el.shadowRoot!.querySelectorAll(".object-block .object-name");
|
||||
const names = [...sections].map(n => n.textContent?.trim() || "");
|
||||
expect(names, "object sections sorted A→Z").to.deep.equal([
|
||||
"Aqua Pool",
|
||||
"Mid Garage",
|
||||
"Zenith Compressor",
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders tasks within each object alphabetically", async () => {
|
||||
const el = await mount();
|
||||
const blocks = el.shadowRoot!.querySelectorAll(".object-block");
|
||||
// Aqua Pool block (first after sort) should have tasks A→Z
|
||||
const aquaTasks = blocks[0].querySelectorAll(".task-row span");
|
||||
const taskNames = [...aquaTasks].map(s => s.textContent?.trim() || "");
|
||||
expect(taskNames, "Aqua Pool tasks A→Z").to.deep.equal([
|
||||
"Add chlorine",
|
||||
"Brush walls",
|
||||
"pH check",
|
||||
]);
|
||||
});
|
||||
|
||||
it("toggles a checkbox and stores the composite entry:task key", async () => {
|
||||
const el = await mount();
|
||||
const blocks = el.shadowRoot!.querySelectorAll(".object-block");
|
||||
const firstCheckbox = blocks[0].querySelector<HTMLInputElement>(".task-row input[type=checkbox]")!;
|
||||
firstCheckbox.checked = true;
|
||||
firstCheckbox.dispatchEvent(new Event("change"));
|
||||
await el.updateComplete;
|
||||
// First sorted object is Aqua Pool (e1); first sorted task is "Add chlorine" (t13)
|
||||
// Internal _selected uses entry_id:task_id. Inspect via the rendered count.
|
||||
const countEl = el.shadowRoot!.querySelector(".selected-count");
|
||||
expect(countEl?.textContent || "").to.match(/1\b/);
|
||||
});
|
||||
|
||||
it("checkbox state survives object re-render via property update", async () => {
|
||||
const el = await mount();
|
||||
const blocks1 = el.shadowRoot!.querySelectorAll(".object-block");
|
||||
const cb = blocks1[0].querySelector<HTMLInputElement>(".task-row input")!;
|
||||
cb.checked = true;
|
||||
cb.dispatchEvent(new Event("change"));
|
||||
await el.updateComplete;
|
||||
|
||||
// Force a re-render with the same objects (ordering invariant).
|
||||
el.objects = [...mockObjects()];
|
||||
await el.updateComplete;
|
||||
|
||||
const cbAgain = el.shadowRoot!.querySelectorAll(".object-block")[0]
|
||||
.querySelector<HTMLInputElement>(".task-row input")!;
|
||||
expect(cbAgain.checked, "checked state preserved across re-render").to.be.true;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* i18n runtime-loader guard.
|
||||
*
|
||||
* The panel/card UI strings used to be one big inline `TRANSLATIONS` object in
|
||||
* styles.ts. They now live in `frontend-src/locales/<lang>.json`: only English
|
||||
* is bundled (imported by styles.ts) as the always-available fallback; the other
|
||||
* 12 languages are fetched at runtime from `/maintenance_supporter_locales/` —
|
||||
* so a translation edit needs no bundle rebuild (the stale-bundle pitfall fix).
|
||||
*
|
||||
* Cross-locale KEY PARITY is guarded in Python (`tests/test_i18n.py` reads every
|
||||
* `frontend-src/locales/*.json`). This browser test guards the runtime LOADER
|
||||
* behaviour that replaced the inline tables: bundled-EN, English fallback, and
|
||||
* the no-fetch fast paths of ensureLocale/isLocaleLoaded.
|
||||
*/
|
||||
import { expect } from "@open-wc/testing";
|
||||
import { t, isLocaleLoaded, ensureLocale } from "../styles";
|
||||
|
||||
describe("i18n runtime loader", () => {
|
||||
it("serves bundled English synchronously", () => {
|
||||
// A key known to exist in en.json resolves with no fetch and isn't the key.
|
||||
expect(t("loading", "en")).to.be.a("string").and.not.equal("loading");
|
||||
});
|
||||
|
||||
it("falls back to English for an unloaded language, then to the key", () => {
|
||||
// German isn't bundled; before load an EN-present key falls back to EN…
|
||||
expect(t("loading", "de")).to.equal(t("loading", "en"));
|
||||
// …and an unknown key returns the key itself.
|
||||
expect(t("__nonexistent_key__", "de")).to.equal("__nonexistent_key__");
|
||||
});
|
||||
|
||||
it("treats English (and its regional variants) as always loaded", () => {
|
||||
expect(isLocaleLoaded("en")).to.be.true;
|
||||
expect(isLocaleLoaded("en-GB")).to.be.true; // normalises to "en"
|
||||
expect(isLocaleLoaded("de")).to.be.false; // not fetched yet
|
||||
});
|
||||
|
||||
it("ensureLocale resolves immediately for English and unsupported langs", async () => {
|
||||
await ensureLocale("en"); // bundled → no-op
|
||||
await ensureLocale("xx"); // unsupported → no fetch, stays on English
|
||||
expect(isLocaleLoaded("xx")).to.be.false;
|
||||
});
|
||||
|
||||
it("shares the locale store across bundle copies via window (German-panel/English-dialog regression)", () => {
|
||||
// maintenance-card.js is loaded on EVERY page (extra_module_url) and its
|
||||
// custom-element definitions win first — so a dialog inside the panel
|
||||
// executes the CARD bundle's copy of styles.ts. If each copy had its own
|
||||
// module-scoped store, the panel's locale load would never reach the
|
||||
// dialog: German panel, English "Edit Task" dialog (the v2.17.0 bug).
|
||||
// Guard: the store is window-scoped, so writing to the global (as another
|
||||
// bundle copy would) is immediately visible to this copy's t().
|
||||
const g = (window as unknown as {
|
||||
__msLocales?: { store: Record<string, Record<string, string>> };
|
||||
}).__msLocales;
|
||||
expect(g, "window.__msLocales must back the locale store").to.exist;
|
||||
g!.store.pt = { loading: "A carregar (from another bundle copy)" };
|
||||
try {
|
||||
expect(t("loading", "pt")).to.equal("A carregar (from another bundle copy)");
|
||||
expect(isLocaleLoaded("pt")).to.be.true;
|
||||
} finally {
|
||||
delete g!.store.pt;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Unit-aware interval math (issue #59) — progress bars + calendar projection
|
||||
* must treat weeks/months/years as their real day-span, not the raw count.
|
||||
*/
|
||||
import { expect } from "@open-wc/testing";
|
||||
import { intervalSpanDays, daysProgress } from "../helpers/interval";
|
||||
|
||||
describe("intervalSpanDays (#59)", () => {
|
||||
it("days = raw count", () => expect(intervalSpanDays(7, "days")).to.equal(7));
|
||||
it("weeks × 7", () => expect(intervalSpanDays(2, "weeks")).to.equal(14));
|
||||
it("months ≈ 30.44/mo", () =>
|
||||
expect(intervalSpanDays(3, "months")).to.be.closeTo(91.3, 0.5));
|
||||
it("years ≈ 365.25", () =>
|
||||
expect(intervalSpanDays(1, "years")).to.be.closeTo(365.25, 0.01));
|
||||
it("missing unit defaults to days", () =>
|
||||
expect(intervalSpanDays(5)).to.equal(5));
|
||||
it("zero / null → 0", () => {
|
||||
expect(intervalSpanDays(0, "years")).to.equal(0);
|
||||
expect(intervalSpanDays(null, "years")).to.equal(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("daysProgress (#59)", () => {
|
||||
it("yearly task halfway → ~50% (pre-fix clamped to 0)", () => {
|
||||
const p = daysProgress(1, 182, "years");
|
||||
expect(p.pct).to.be.closeTo(50, 1);
|
||||
expect(p.overflow).to.equal(false);
|
||||
});
|
||||
it("yearly task overdue → 100% + overflow", () => {
|
||||
const p = daysProgress(1, -10, "years");
|
||||
expect(p.pct).to.equal(100);
|
||||
expect(p.overflow).to.equal(true);
|
||||
});
|
||||
it("days unit unchanged: just performed → 0%", () =>
|
||||
expect(daysProgress(30, 30, "days").pct).to.equal(0));
|
||||
it("days unit: due today → 100%", () =>
|
||||
expect(daysProgress(30, 0, "days").pct).to.equal(100));
|
||||
it("monthly task: 1 of ~30 days elapsed → small %", () => {
|
||||
const p = daysProgress(1, 29, "months");
|
||||
expect(p.pct).to.be.greaterThan(0);
|
||||
expect(p.pct).to.be.lessThan(15);
|
||||
});
|
||||
it("no interval / null countdown → 0", () => {
|
||||
expect(daysProgress(null, 5, "days").pct).to.equal(0);
|
||||
expect(daysProgress(30, null, "days").pct).to.equal(0);
|
||||
});
|
||||
});
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Tripwire: the strategy's document-level `ll-custom` handler must accept BOTH
|
||||
* payload shapes that reach it in production.
|
||||
*
|
||||
* 1. HA's `tap_action: { action: "fire-dom-event", ll_custom: {...} }` path
|
||||
* (the empty-state "Add object" button) dispatches `ll-custom` with the
|
||||
* WHOLE action config as the event detail — our payload nests under
|
||||
* `.ll_custom`. This is the shape that broke in issue #69: the handler
|
||||
* read `detail.type` (undefined) and silently did nothing.
|
||||
*
|
||||
* 2. Direct dispatchers — the calendar card and the panel — put the payload
|
||||
* at the TOP level of the detail (`{ type, entry_id, task_id }`).
|
||||
*
|
||||
* Both must open the create-object dialog. The pre-#69 verifier only exercised
|
||||
* shape 2, which is why the regression shipped. If you change how the handler
|
||||
* reads the payload, keep both green.
|
||||
*/
|
||||
|
||||
import { expect } from "@open-wc/testing";
|
||||
import { createMockHass } from "./_test-utils.js";
|
||||
|
||||
const OBJECT_DIALOG_TAG = "maintenance-object-dialog";
|
||||
const tick = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// A fake <home-assistant> with a mock hass so dialog-mount's getHass() succeeds
|
||||
// and openCreateObjectDialog() returns true (no deep-link fallback / no URL
|
||||
// mutation of the test page).
|
||||
let haRoot: HTMLElement & { hass?: unknown };
|
||||
|
||||
before(async () => {
|
||||
const { hass } = createMockHass();
|
||||
haRoot = document.createElement("home-assistant") as HTMLElement & { hass?: unknown };
|
||||
haRoot.hass = hass;
|
||||
document.body.appendChild(haRoot);
|
||||
// Import for its side effect: registers the document-level ll-custom handler.
|
||||
await import("../maintenance-dashboard-strategy.js");
|
||||
});
|
||||
|
||||
after(() => {
|
||||
haRoot?.remove();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.querySelector(OBJECT_DIALOG_TAG)?.remove();
|
||||
});
|
||||
|
||||
async function fireAndWait(detail: unknown): Promise<HTMLElement | null> {
|
||||
document.dispatchEvent(
|
||||
new CustomEvent("ll-custom", { detail, bubbles: true, composed: true }),
|
||||
);
|
||||
// handler imports dialog-mount.ts dynamically, then mounts the dialog
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const dlg = document.body.querySelector<HTMLElement>(OBJECT_DIALOG_TAG);
|
||||
if (dlg) return dlg;
|
||||
await tick(25);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("ll-custom handler payload shape (#69)", () => {
|
||||
it("opens the object dialog for HA's nested fire-dom-event shape", async () => {
|
||||
const dlg = await fireAndWait({
|
||||
action: "fire-dom-event",
|
||||
ll_custom: { type: "maintenance-supporter:add-object" },
|
||||
});
|
||||
expect(dlg, "nested ll_custom payload must reach the add-object handler").to.not.equal(null);
|
||||
});
|
||||
|
||||
it("opens the object dialog for the top-level (calendar/panel) shape", async () => {
|
||||
const dlg = await fireAndWait({ type: "maintenance-supporter:add-object" });
|
||||
expect(dlg, "top-level payload must still work (calendar card / panel)").to.not.equal(null);
|
||||
});
|
||||
|
||||
it("ignores ll-custom events for other namespaces", async () => {
|
||||
document.dispatchEvent(
|
||||
new CustomEvent("ll-custom", {
|
||||
detail: { action: "fire-dom-event", ll_custom: { type: "browser_mod:foo" } },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
await tick(150);
|
||||
expect(document.body.querySelector(OBJECT_DIALOG_TAG)).to.equal(null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Objects-table column catalog + sanitiser (#67). Mirrors the backend
|
||||
* sanitise so the panel and Settings UI agree on what's valid.
|
||||
*/
|
||||
import { expect } from "@open-wc/testing";
|
||||
import {
|
||||
sanitizeColumns,
|
||||
DEFAULT_OBJECTS_TABLE_COLUMNS,
|
||||
KNOWN_OBJECT_COLUMNS,
|
||||
OBJECT_COLUMNS,
|
||||
} from "../helpers/object-columns";
|
||||
|
||||
describe("sanitizeColumns (#67)", () => {
|
||||
it("non-array → defaults", () => {
|
||||
expect(sanitizeColumns(undefined)).to.deep.equal(DEFAULT_OBJECTS_TABLE_COLUMNS);
|
||||
expect(sanitizeColumns(null)).to.deep.equal(DEFAULT_OBJECTS_TABLE_COLUMNS);
|
||||
expect(sanitizeColumns("name")).to.deep.equal(DEFAULT_OBJECTS_TABLE_COLUMNS);
|
||||
});
|
||||
|
||||
it("empty / all-unknown → defaults", () => {
|
||||
expect(sanitizeColumns([])).to.deep.equal(DEFAULT_OBJECTS_TABLE_COLUMNS);
|
||||
expect(sanitizeColumns(["nope", 7, null])).to.deep.equal(DEFAULT_OBJECTS_TABLE_COLUMNS);
|
||||
});
|
||||
|
||||
it("drops unknown keys, preserves caller order", () => {
|
||||
expect(sanitizeColumns(["name", "bogus", "warranty_expiry"]))
|
||||
.to.deep.equal(["name", "warranty_expiry"]);
|
||||
});
|
||||
|
||||
it("dedupes", () => {
|
||||
expect(sanitizeColumns(["name", "name", "model"]))
|
||||
.to.deep.equal(["name", "model"]);
|
||||
});
|
||||
|
||||
it("prepends the required name column when missing", () => {
|
||||
expect(sanitizeColumns(["warranty_expiry", "model"]))
|
||||
.to.deep.equal(["name", "warranty_expiry", "model"]);
|
||||
});
|
||||
|
||||
it("accepts a full custom subset unchanged", () => {
|
||||
const cols = ["name", "warranty_expiry", "actions"];
|
||||
expect(sanitizeColumns(cols)).to.deep.equal(cols);
|
||||
});
|
||||
|
||||
it("catalog is internally consistent", () => {
|
||||
expect(KNOWN_OBJECT_COLUMNS.length).to.equal(OBJECT_COLUMNS.length);
|
||||
for (const c of OBJECT_COLUMNS) {
|
||||
expect(c.labelKey, c.key).to.be.a("string").and.not.equal("");
|
||||
}
|
||||
for (const k of DEFAULT_OBJECTS_TABLE_COLUMNS) {
|
||||
expect(KNOWN_OBJECT_COLUMNS, k).to.include(k);
|
||||
}
|
||||
});
|
||||
});
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* QR deep-link routing tests (audit gap #9).
|
||||
*
|
||||
* A scanned QR code lands on the panel URL with ?entry_id&task_id&action=…
|
||||
* — until now only the URL *building* was tested, never the handling. These
|
||||
* tests pin the scan-to-complete story at the routing layer:
|
||||
* - action=complete opens the pre-targeted complete dialog on the task view
|
||||
* - action=quick_complete fires task/quick_complete silently
|
||||
* - a no_defaults refusal falls back to the normal complete dialog
|
||||
* - params are consumed once (cleaned from the URL)
|
||||
* - an unknown entry_id lands safely on the overview
|
||||
*/
|
||||
|
||||
import { expect } from "@open-wc/testing";
|
||||
import type { MaintenanceCompleteDialog } from "../components/complete-dialog";
|
||||
import { mountPanel, obj, resetTaskSeq, sr, task } from "./_panel-utils.js";
|
||||
|
||||
function setDeepLink(query: string) {
|
||||
history.replaceState(null, "", `${window.location.pathname}?${query}`);
|
||||
}
|
||||
|
||||
async function settleRaf(el: { updateComplete: Promise<unknown> }) {
|
||||
// Deep-link dialog opens behind a requestAnimationFrame.
|
||||
await new Promise((r) => requestAnimationFrame(() => r(null)));
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
await (el as HTMLElement & { updateComplete: Promise<unknown> }).updateComplete;
|
||||
}
|
||||
|
||||
function completeDialog(el: HTMLElement): MaintenanceCompleteDialog | null {
|
||||
return sr(el).querySelector<MaintenanceCompleteDialog>("maintenance-complete-dialog");
|
||||
}
|
||||
|
||||
describe("panel deep links (QR scan routing)", () => {
|
||||
beforeEach(() => {
|
||||
resetTaskSeq();
|
||||
localStorage.clear();
|
||||
localStorage.setItem("msp-overview-tab", "dashboard");
|
||||
});
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
history.replaceState(null, "", window.location.pathname);
|
||||
});
|
||||
|
||||
it("?action=complete lands on the task and opens the pre-targeted dialog", async () => {
|
||||
setDeepLink("entry_id=e1&task_id=t1&action=complete");
|
||||
const { el } = await mountPanel([
|
||||
obj("e1", [task({ name: "Scan Me" })]),
|
||||
]);
|
||||
await settleRaf(el);
|
||||
|
||||
// Landed on the task detail…
|
||||
expect(sr(el).querySelector(".task-header"), "task detail rendered").to.exist;
|
||||
expect(sr(el).querySelector(".task-name-breadcrumb")!.textContent).to.include("Scan Me");
|
||||
// …with the complete dialog open and targeted at the scanned task.
|
||||
const dlg = completeDialog(el)!;
|
||||
expect(dlg.shadowRoot!.querySelector("ha-dialog"), "complete dialog open").to.exist;
|
||||
expect(dlg.entryId).to.equal("e1");
|
||||
expect(dlg.taskId).to.equal("t1");
|
||||
expect(dlg.taskName).to.equal("Scan Me");
|
||||
// Params are consumed once: the URL is cleaned.
|
||||
expect(window.location.search).to.equal("");
|
||||
});
|
||||
|
||||
it("?action=quick_complete fires task/quick_complete silently", async () => {
|
||||
setDeepLink("entry_id=e1&task_id=t1&action=quick_complete");
|
||||
const { el, sent } = await mountPanel(
|
||||
[obj("e1", [task({ name: "Quick" })])],
|
||||
{ "maintenance_supporter/task/quick_complete": () => ({ success: true, via: "quick" }) },
|
||||
);
|
||||
await settleRaf(el);
|
||||
|
||||
const quick = sent.filter((m) => m.type === "maintenance_supporter/task/quick_complete");
|
||||
expect(quick.length).to.equal(1);
|
||||
expect(quick[0].entry_id).to.equal("e1");
|
||||
expect(quick[0].task_id).to.equal("t1");
|
||||
// Silent path: no dialog opened.
|
||||
const dlg = completeDialog(el);
|
||||
expect(dlg?.shadowRoot?.querySelector("ha-dialog") ?? null).to.be.null;
|
||||
});
|
||||
|
||||
it("quick_complete without defaults falls back to the complete dialog", async () => {
|
||||
setDeepLink("entry_id=e1&task_id=t1&action=quick_complete");
|
||||
const { el, sent } = await mountPanel(
|
||||
[obj("e1", [task({ name: "No Defaults" })])],
|
||||
{
|
||||
"maintenance_supporter/task/quick_complete": () => {
|
||||
throw { code: "no_defaults", message: "open the dialog instead" };
|
||||
},
|
||||
},
|
||||
);
|
||||
await settleRaf(el);
|
||||
await settleRaf(el); // fallback opens after the rejected promise settles
|
||||
|
||||
expect(
|
||||
sent.filter((m) => m.type === "maintenance_supporter/task/quick_complete").length,
|
||||
).to.equal(1);
|
||||
const dlg = completeDialog(el)!;
|
||||
expect(dlg.shadowRoot!.querySelector("ha-dialog"), "fallback dialog open").to.exist;
|
||||
expect(dlg.taskName).to.equal("No Defaults");
|
||||
});
|
||||
|
||||
it("an unknown entry_id lands safely on the overview", async () => {
|
||||
setDeepLink("entry_id=does-not-exist&task_id=t1&action=complete");
|
||||
const { el } = await mountPanel([obj("e1", [task({ name: "Real" })])]);
|
||||
await settleRaf(el);
|
||||
|
||||
expect(sr(el).querySelector(".task-header")).to.be.null;
|
||||
expect(sr(el).querySelector(".task-table"), "overview dashboard rendered").to.exist;
|
||||
expect(window.location.search).to.equal("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
import { expect } from "@open-wc/testing";
|
||||
import { mountPanel, obj, resetTaskSeq, sr, task } from "./_panel-utils.js";
|
||||
describe("panel shell", () => {
|
||||
beforeEach(() => {
|
||||
resetTaskSeq();
|
||||
localStorage.clear();
|
||||
localStorage.setItem("msp-overview-tab", "dashboard");
|
||||
});
|
||||
afterEach(() => localStorage.clear());
|
||||
|
||||
it("bulk select-all covers visible rows only and bulk Complete sends one call per selection", async () => {
|
||||
const { el, sent } = await mountPanel([
|
||||
obj("e1", [
|
||||
task({ name: "Active A" }),
|
||||
task({ name: "Active B" }),
|
||||
task({ name: "Active C" }),
|
||||
task({ name: "Gone", archived: true }),
|
||||
]),
|
||||
]);
|
||||
|
||||
// Enter bulk mode.
|
||||
sr(el).querySelector<HTMLElement>(".bulk-toggle")!.click();
|
||||
await el.updateComplete;
|
||||
expect(sr(el).querySelector(".bulk-bar"), "bulk bar visible").to.exist;
|
||||
|
||||
// Select all → exactly the 3 visible (non-archived) rows get checkboxes.
|
||||
sr(el).querySelector<HTMLInputElement>(".bulk-selectall input")!.click();
|
||||
await el.updateComplete;
|
||||
const checked = [...sr(el).querySelectorAll<HTMLInputElement>(".bulk-check input")]
|
||||
.filter((c) => c.checked);
|
||||
expect(checked.length).to.equal(3);
|
||||
|
||||
// Bulk Complete → one task/complete per selected task, none for archived.
|
||||
const completeBtn = sr(el).querySelector<HTMLElement>(".bulk-actions ha-button")!;
|
||||
completeBtn.click();
|
||||
await new Promise((r) => setTimeout(r, 40));
|
||||
await el.updateComplete;
|
||||
|
||||
const completes = sent.filter((m) => m.type === "maintenance_supporter/task/complete");
|
||||
expect(completes.length).to.equal(3);
|
||||
expect(new Set(completes.map((m) => m.task_id))).to.deep.equal(
|
||||
new Set(["t1", "t2", "t3"]),
|
||||
);
|
||||
// Bulk mode exits after the action.
|
||||
expect(sr(el).querySelector(".bulk-bar")).to.be.null;
|
||||
});
|
||||
|
||||
it("bulk Archive sends task/archive for the manually selected rows only", async () => {
|
||||
const { el, sent } = await mountPanel([
|
||||
obj("e1", [task({ name: "One" }), task({ name: "Two" }), task({ name: "Three" })]),
|
||||
]);
|
||||
|
||||
sr(el).querySelector<HTMLElement>(".bulk-toggle")!.click();
|
||||
await el.updateComplete;
|
||||
|
||||
// Tick rows 1 and 3 via their row checkboxes.
|
||||
const boxes = [...sr(el).querySelectorAll<HTMLInputElement>(".bulk-check input")];
|
||||
boxes[0].click();
|
||||
boxes[2].click();
|
||||
await el.updateComplete;
|
||||
|
||||
// Second bulk action button is Archive.
|
||||
const actions = [...sr(el).querySelectorAll<HTMLElement>(".bulk-actions ha-button")];
|
||||
actions[1].click();
|
||||
await new Promise((r) => setTimeout(r, 40));
|
||||
await el.updateComplete;
|
||||
|
||||
const archives = sent.filter((m) => m.type === "maintenance_supporter/task/archive");
|
||||
expect(archives.length).to.equal(2);
|
||||
expect(new Set(archives.map((m) => m.task_id))).to.deep.equal(new Set(["t1", "t3"]));
|
||||
expect(sent.filter((m) => m.type === "maintenance_supporter/task/complete").length)
|
||||
.to.equal(0);
|
||||
});
|
||||
|
||||
it("'/' opens the command palette, filters, and navigates to the task", async () => {
|
||||
const { el } = await mountPanel([
|
||||
obj("e1", [task({ name: "Filter Wechsel" }), task({ name: "Pumpe prüfen" })]),
|
||||
]);
|
||||
|
||||
// Ctrl+K must NOT open it — that's HA's own global-search hotkey and the
|
||||
// panel used to shadow it (changed in 2.18.1).
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", { key: "k", ctrlKey: true }));
|
||||
await el.updateComplete;
|
||||
expect(sr(el).querySelector(".palette-input"), "Ctrl+K stays HA's").to.be.null;
|
||||
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", { key: "/" }));
|
||||
await el.updateComplete;
|
||||
const input = sr(el).querySelector<HTMLInputElement>(".palette-input");
|
||||
expect(input, "palette opened").to.exist;
|
||||
|
||||
input!.value = "Filter";
|
||||
input!.dispatchEvent(new Event("input"));
|
||||
await el.updateComplete;
|
||||
|
||||
const results = [...sr(el).querySelectorAll(".palette-results .palette-label")]
|
||||
.map((r) => r.textContent?.trim());
|
||||
expect(results).to.include("Filter Wechsel");
|
||||
expect(results).to.not.include("Pumpe prüfen");
|
||||
|
||||
// Click the task result → task detail renders.
|
||||
const hit = [...sr(el).querySelectorAll<HTMLElement>(".palette-results > *")]
|
||||
.find((r) => /Filter Wechsel/.test(r.textContent || ""))!;
|
||||
hit.click();
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
await el.updateComplete;
|
||||
expect(sr(el).querySelector(".palette-input"), "palette closed").to.be.null;
|
||||
expect(sr(el).querySelector(".task-header"), "task detail rendered").to.exist;
|
||||
expect(sr(el).querySelector(".task-name-breadcrumb")!.textContent)
|
||||
.to.include("Filter Wechsel");
|
||||
});
|
||||
|
||||
it("'/' while typing in a text field does not open the palette", async () => {
|
||||
const { el } = await mountPanel([
|
||||
obj("e1", [task({ name: "Filter Wechsel" })]),
|
||||
]);
|
||||
const field = document.createElement("input");
|
||||
document.body.appendChild(field);
|
||||
try {
|
||||
field.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "/", bubbles: true, composed: true }),
|
||||
);
|
||||
await el.updateComplete;
|
||||
expect(sr(el).querySelector(".palette-input")).to.be.null;
|
||||
} finally {
|
||||
field.remove();
|
||||
}
|
||||
});
|
||||
|
||||
it("Today view buckets overdue / due-today / this-week and hides later tasks", async () => {
|
||||
localStorage.setItem("msp-overview-tab", "today");
|
||||
const { el } = await mountPanel([
|
||||
obj("e1", [
|
||||
task({ name: "Late", status: "overdue", days_until_due: -3 }),
|
||||
task({ name: "Now", status: "due_soon", days_until_due: 0 }),
|
||||
task({ name: "Soon", status: "due_soon", days_until_due: 3 }),
|
||||
task({ name: "Later", status: "ok", days_until_due: 20 }),
|
||||
]),
|
||||
]);
|
||||
|
||||
const view = sr(el).querySelector(".today-view");
|
||||
expect(view, "today view rendered").to.exist;
|
||||
const sections = [...sr(el).querySelectorAll(".today-section")];
|
||||
const byHeader = (re: RegExp) =>
|
||||
sections.find((s) => re.test(s.querySelector(".today-section-header")!.textContent || ""));
|
||||
|
||||
const textOf = (s: Element | undefined) =>
|
||||
[...(s?.querySelectorAll(".today-task") || [])].map((t2) => t2.textContent?.trim());
|
||||
|
||||
const all = sections.flatMap((s) => textOf(s));
|
||||
expect(all).to.include("Late");
|
||||
expect(all).to.include("Now");
|
||||
expect(all).to.include("Soon");
|
||||
expect(all).to.not.include("Later");
|
||||
// Overdue section leads with the late task.
|
||||
const overdueSection = byHeader(/overdue|überfällig/i);
|
||||
expect(textOf(overdueSection)).to.include("Late");
|
||||
});
|
||||
|
||||
it("virtualizes the table above the threshold and moves the window on scroll", async () => {
|
||||
const many = Array.from({ length: 150 }, (_, i) =>
|
||||
task({ name: `Bulk ${String(i).padStart(3, "0")}`, days_until_due: (i % 40) + 1 }),
|
||||
);
|
||||
const { el } = await mountPanel([obj("e1", many)]);
|
||||
|
||||
const table = sr(el).querySelector(".task-table");
|
||||
expect(table, "table rendered").to.exist;
|
||||
expect(table!.classList.contains("virtual"), "virtual mode active").to.be.true;
|
||||
|
||||
const domRows = () =>
|
||||
[...sr(el).querySelectorAll(".task-table .task-row:not(.virt-sizer)")];
|
||||
expect(domRows().length).to.be.lessThan(120);
|
||||
expect(domRows().length).to.be.greaterThan(5);
|
||||
|
||||
// Scroll the content container → the window shifts and a top spacer grows.
|
||||
const content = sr(el).querySelector<HTMLElement>(".content")!;
|
||||
content.scrollTop = 3000;
|
||||
content.dispatchEvent(new Event("scroll"));
|
||||
await new Promise((r) => requestAnimationFrame(() => r(null)));
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
await el.updateComplete;
|
||||
|
||||
const spacer = sr(el).querySelector<HTMLElement>(".task-table .virt-spacer");
|
||||
expect(spacer, "top spacer present after scroll").to.exist;
|
||||
expect(parseInt(spacer!.style.height, 10)).to.be.greaterThan(0);
|
||||
const firstName = domRows()[0]?.querySelector(".task-name")?.textContent?.trim();
|
||||
expect(firstName).to.not.equal("Bulk 000");
|
||||
});
|
||||
});
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Lit component tests for the notify-target picker (datalist) in the general
|
||||
* section of <maintenance-settings-view>.
|
||||
*
|
||||
* The pickable-target LIST is computed server-side by build_notify_targets and
|
||||
* arrives as `settings.general.notify_targets` (merging legacy notify services +
|
||||
* notify entities, minus the generic send_message, plus the saved value). The
|
||||
* panel's only job is to render that list into a <datalist> while keeping the
|
||||
* input free-text. The merge logic itself is covered by the Python tests for
|
||||
* build_notify_targets — here we only assert the panel mirrors what it's given.
|
||||
* See settings-view.ts::_renderGeneral.
|
||||
*/
|
||||
|
||||
import { expect, fixture, html } from "@open-wc/testing";
|
||||
import "../components/settings-view.js";
|
||||
import type { MaintenanceSettingsView } from "../components/settings-view";
|
||||
import {
|
||||
DEFAULT_FEATURES,
|
||||
DEFAULT_SETTINGS_RESPONSE,
|
||||
createMockHass,
|
||||
} from "./_test-utils.js";
|
||||
|
||||
async function mount(notifyTargets?: string[]): Promise<MaintenanceSettingsView> {
|
||||
const { hass } = createMockHass({
|
||||
settingsResponse: {
|
||||
...DEFAULT_SETTINGS_RESPONSE,
|
||||
general: {
|
||||
...DEFAULT_SETTINGS_RESPONSE.general,
|
||||
notifications_enabled: true,
|
||||
notify_service: "notify.mobile_app_phone",
|
||||
...(notifyTargets !== undefined ? { notify_targets: notifyTargets } : {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
const el = await fixture<MaintenanceSettingsView>(html`
|
||||
<maintenance-settings-view .hass=${hass} .features=${DEFAULT_FEATURES}></maintenance-settings-view>
|
||||
`);
|
||||
// _loadSettings is kicked off by updated() after first render.
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
await el.updateComplete;
|
||||
return el;
|
||||
}
|
||||
|
||||
function options(el: MaintenanceSettingsView): string[] {
|
||||
const list = el.shadowRoot?.querySelector<HTMLDataListElement>("#ms-notify-services");
|
||||
return Array.from(list?.querySelectorAll("option") ?? []).map((o) => o.value);
|
||||
}
|
||||
|
||||
describe("settings-view notify-target picker", () => {
|
||||
it("renders the server-provided notify_targets verbatim as datalist options", async () => {
|
||||
const el = await mount([
|
||||
"notify.mobile_app_phone",
|
||||
"notify.all_devices_group",
|
||||
"notify.file",
|
||||
]);
|
||||
|
||||
const opts = options(el);
|
||||
expect(opts).to.include("notify.mobile_app_phone");
|
||||
expect(opts).to.include("notify.all_devices_group");
|
||||
expect(opts).to.include("notify.file");
|
||||
expect(opts).to.have.lengthOf(3);
|
||||
});
|
||||
|
||||
it("degrades to an empty datalist when no notify targets are provided", async () => {
|
||||
const el = await mount([]);
|
||||
const list = el.shadowRoot?.querySelector<HTMLDataListElement>("#ms-notify-services");
|
||||
expect(list, "datalist still present").to.exist;
|
||||
expect(list!.querySelectorAll("option").length, "no options").to.equal(0);
|
||||
// Free-text input still renders so notifications can be configured.
|
||||
const input = el.shadowRoot?.querySelector<HTMLInputElement>(
|
||||
'input[list="ms-notify-services"]',
|
||||
);
|
||||
expect(input, "free-text input still present").to.exist;
|
||||
});
|
||||
|
||||
it("degrades gracefully when an older backend omits notify_targets", async () => {
|
||||
// notify_targets absent entirely (undefined) → no options, no crash.
|
||||
const { hass } = createMockHass({
|
||||
settingsResponse: {
|
||||
...DEFAULT_SETTINGS_RESPONSE,
|
||||
general: {
|
||||
default_warning_days: 7,
|
||||
notifications_enabled: true,
|
||||
notify_service: "notify.mobile_app_phone",
|
||||
panel_enabled: false,
|
||||
// notify_targets deliberately omitted
|
||||
} as unknown as (typeof DEFAULT_SETTINGS_RESPONSE)["general"],
|
||||
},
|
||||
});
|
||||
const el = await fixture<MaintenanceSettingsView>(html`
|
||||
<maintenance-settings-view .hass=${hass} .features=${DEFAULT_FEATURES}></maintenance-settings-view>
|
||||
`);
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
await el.updateComplete;
|
||||
expect(options(el), "no options when omitted").to.have.lengthOf(0);
|
||||
});
|
||||
});
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Lit component tests for the Print QR section of <maintenance-settings-view>.
|
||||
*
|
||||
* Covers v1.1.0 batch-QR-print UI: load-objects flow, action chip toggles,
|
||||
* generate button enable/disable, results rendering.
|
||||
*/
|
||||
|
||||
import { expect, fixture, html } from "@open-wc/testing";
|
||||
import "../components/settings-view.js";
|
||||
import type { MaintenanceSettingsView } from "../components/settings-view";
|
||||
import { DEFAULT_FEATURES, createMockHass } from "./_test-utils.js";
|
||||
|
||||
function mockHass(opts: {
|
||||
objects?: Array<{ entry_id: string; name: string; task_count: number }>;
|
||||
batchResult?: Array<{ task_id: string; entry_id: string; object_name: string; task_name: string; action: string; svg: string }>;
|
||||
} = {}) {
|
||||
const objects = opts.objects ?? [
|
||||
{ entry_id: "e1", name: "Pool Pump", task_count: 3 },
|
||||
{ entry_id: "e2", name: "HVAC", task_count: 2 },
|
||||
];
|
||||
return createMockHass({
|
||||
handlers: {
|
||||
"maintenance_supporter/objects": () => ({
|
||||
objects: objects.map(o => ({
|
||||
entry_id: o.entry_id,
|
||||
object: { name: o.name },
|
||||
tasks: Array.from({ length: o.task_count }, (_, i) => ({
|
||||
id: `${o.entry_id}_t${i}`, name: `Task ${i}`,
|
||||
})),
|
||||
})),
|
||||
}),
|
||||
"maintenance_supporter/qr/batch_generate": () => ({
|
||||
qrs: opts.batchResult ?? [
|
||||
{
|
||||
entry_id: "e1", task_id: "e1_t0",
|
||||
object_name: "Pool Pump", task_name: "Task 0",
|
||||
action: "view", svg: "<svg><rect/></svg>",
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function mount(opts = {}) {
|
||||
const { hass, sent } = mockHass(opts);
|
||||
const el = await fixture<MaintenanceSettingsView>(html`
|
||||
<maintenance-settings-view .hass=${hass} .features=${DEFAULT_FEATURES}></maintenance-settings-view>
|
||||
`);
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
await el.updateComplete;
|
||||
return { el, sent };
|
||||
}
|
||||
|
||||
function qrSection(el: MaintenanceSettingsView): HTMLElement | null {
|
||||
return el.shadowRoot?.querySelector(".qr-print-section") || null;
|
||||
}
|
||||
|
||||
describe("settings-view print QR section", () => {
|
||||
it("renders the section with a Load objects button by default", async () => {
|
||||
const { el } = await mount();
|
||||
const section = qrSection(el);
|
||||
expect(section, "section exists").to.exist;
|
||||
const buttons = section!.querySelectorAll("button");
|
||||
const loadBtn = [...buttons].find(b => /load/i.test(b.textContent || ""));
|
||||
expect(loadBtn, "Load objects button present").to.exist;
|
||||
});
|
||||
|
||||
it("loads objects on click and renders the filter panel", async () => {
|
||||
const { el, sent } = await mount();
|
||||
const loadBtn = [...qrSection(el)!.querySelectorAll("button")]
|
||||
.find(b => /load/i.test(b.textContent || ""))!;
|
||||
loadBtn.click();
|
||||
await new Promise(r => setTimeout(r, 30));
|
||||
await el.updateComplete;
|
||||
|
||||
const objCalled = sent.some(m => m.type === "maintenance_supporter/objects");
|
||||
expect(objCalled, "objects WS called").to.be.true;
|
||||
|
||||
const filterPanel = qrSection(el)!.querySelector(".qr-filter-panel");
|
||||
expect(filterPanel, "filter panel rendered after load").to.exist;
|
||||
const objectRows = qrSection(el)!.querySelectorAll(".qr-object-row");
|
||||
expect(objectRows.length, "object rows match mock count").to.equal(2);
|
||||
});
|
||||
|
||||
it("toggles an action chip and updates active class", async () => {
|
||||
const { el } = await mount();
|
||||
// Trigger load to render the chips
|
||||
const loadBtn = [...qrSection(el)!.querySelectorAll("button")]
|
||||
.find(b => /load/i.test(b.textContent || ""))!;
|
||||
loadBtn.click();
|
||||
await new Promise(r => setTimeout(r, 30));
|
||||
await el.updateComplete;
|
||||
|
||||
const chips = qrSection(el)!.querySelectorAll<HTMLElement>(".qr-action-chip");
|
||||
expect(chips.length, "three action chips rendered").to.equal(3);
|
||||
// Default: only "view" active
|
||||
const viewChip = [...chips].find(c => /view|anzeigen/i.test(c.textContent || ""))!;
|
||||
expect(viewChip.classList.contains("active"), "view chip active by default").to.be.true;
|
||||
|
||||
// Toggle "complete" on
|
||||
const completeChip = [...chips].find(c => /complete|erledigen/i.test(c.textContent || ""))!;
|
||||
const completeInput = completeChip.querySelector<HTMLInputElement>("input")!;
|
||||
completeInput.checked = true;
|
||||
completeInput.dispatchEvent(new Event("change"));
|
||||
await el.updateComplete;
|
||||
expect(completeChip.classList.contains("active"), "complete chip flipped active").to.be.true;
|
||||
});
|
||||
|
||||
it("disables Generate button when no actions selected", async () => {
|
||||
const { el } = await mount();
|
||||
const loadBtn = [...qrSection(el)!.querySelectorAll("button")]
|
||||
.find(b => /load/i.test(b.textContent || ""))!;
|
||||
loadBtn.click();
|
||||
await new Promise(r => setTimeout(r, 30));
|
||||
await el.updateComplete;
|
||||
|
||||
// Toggle the default "view" off
|
||||
const viewChipInput = [...qrSection(el)!.querySelectorAll<HTMLInputElement>(".qr-action-chip input")]
|
||||
.find(i => i.checked)!;
|
||||
viewChipInput.checked = false;
|
||||
viewChipInput.dispatchEvent(new Event("change"));
|
||||
await el.updateComplete;
|
||||
|
||||
const genBtn = [...qrSection(el)!.querySelectorAll<HTMLButtonElement>("button")]
|
||||
.find(b => /generate|generieren/i.test(b.textContent || ""))!;
|
||||
expect(genBtn.disabled, "Generate disabled when 0 actions").to.be.true;
|
||||
});
|
||||
|
||||
it("renders a batch result row after Generate", async () => {
|
||||
const { el, sent } = await mount();
|
||||
const loadBtn = [...qrSection(el)!.querySelectorAll("button")]
|
||||
.find(b => /load/i.test(b.textContent || ""))!;
|
||||
loadBtn.click();
|
||||
await new Promise(r => setTimeout(r, 30));
|
||||
await el.updateComplete;
|
||||
|
||||
const genBtn = [...qrSection(el)!.querySelectorAll<HTMLButtonElement>("button")]
|
||||
.find(b => /generate|generieren/i.test(b.textContent || ""))!;
|
||||
genBtn.click();
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
await el.updateComplete;
|
||||
|
||||
const batchCalled = sent.some(m => m.type === "maintenance_supporter/qr/batch_generate");
|
||||
expect(batchCalled, "batch_generate WS called").to.be.true;
|
||||
|
||||
const cells = qrSection(el)!.querySelectorAll(".qr-print-cell");
|
||||
expect(cells.length, "one result cell rendered").to.equal(1);
|
||||
const labelObj = cells[0].querySelector(".qr-label-obj");
|
||||
expect(labelObj?.textContent).to.equal("Pool Pump");
|
||||
});
|
||||
|
||||
it("warns over-limit when filter would produce > 200 QRs", async () => {
|
||||
// 100 objects with 3 actions = 300 QRs → over the 200 cap.
|
||||
const manyObjects = Array.from({ length: 101 }, (_, i) => ({
|
||||
entry_id: `e${i}`, name: `Obj ${i}`, task_count: 1,
|
||||
}));
|
||||
const { el } = await mount({ objects: manyObjects });
|
||||
const loadBtn = [...qrSection(el)!.querySelectorAll("button")]
|
||||
.find(b => /load/i.test(b.textContent || ""))!;
|
||||
loadBtn.click();
|
||||
await new Promise(r => setTimeout(r, 30));
|
||||
await el.updateComplete;
|
||||
|
||||
// Toggle on all 3 actions
|
||||
const chips = qrSection(el)!.querySelectorAll<HTMLInputElement>(".qr-action-chip input");
|
||||
for (const chip of chips) {
|
||||
if (!chip.checked) {
|
||||
chip.checked = true;
|
||||
chip.dispatchEvent(new Event("change"));
|
||||
}
|
||||
}
|
||||
await el.updateComplete;
|
||||
|
||||
const estimate = qrSection(el)!.querySelector(".qr-estimate");
|
||||
expect(estimate?.classList.contains("error"), "estimate shows error class").to.be.true;
|
||||
const genBtn = [...qrSection(el)!.querySelectorAll<HTMLButtonElement>("button")]
|
||||
.find(b => /generate|generieren/i.test(b.textContent || ""))!;
|
||||
expect(genBtn.disabled, "Generate disabled at over-limit").to.be.true;
|
||||
});
|
||||
});
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Lit component tests for the vacation section of <maintenance-settings-view>.
|
||||
*
|
||||
* Mounts the component with a mocked `hass` (just a connection stub that
|
||||
* captures sendMessagePromise calls) and asserts on rendered output +
|
||||
* outgoing WS messages. No HA shell, no shadow-DOM-deep-piercing —
|
||||
* runs in real Chromium via @web/test-runner.
|
||||
*/
|
||||
|
||||
import { expect, fixture, html, oneEvent } from "@open-wc/testing";
|
||||
import "../components/settings-view.js";
|
||||
import type { MaintenanceSettingsView } from "../components/settings-view";
|
||||
import {
|
||||
DEFAULT_FEATURES,
|
||||
DEFAULT_SETTINGS_RESPONSE,
|
||||
createMockHass,
|
||||
} from "./_test-utils.js";
|
||||
|
||||
function mockHass(opts: {
|
||||
vacationActive?: boolean;
|
||||
vacationStart?: string | null;
|
||||
vacationEnd?: string | null;
|
||||
exemptIds?: string[];
|
||||
} = {}) {
|
||||
const settingsResponse = {
|
||||
...DEFAULT_SETTINGS_RESPONSE,
|
||||
vacation: {
|
||||
...DEFAULT_SETTINGS_RESPONSE.vacation,
|
||||
enabled: opts.vacationActive ?? false,
|
||||
start: opts.vacationStart ?? null,
|
||||
end: opts.vacationEnd ?? null,
|
||||
exempt_task_ids: opts.exemptIds ?? [],
|
||||
is_active: opts.vacationActive ?? false,
|
||||
window_end: opts.vacationEnd ?? null,
|
||||
},
|
||||
};
|
||||
|
||||
return createMockHass({
|
||||
settingsResponse,
|
||||
handlers: {
|
||||
"maintenance_supporter/vacation/update": (msg) => ({
|
||||
// Echo the patch merged onto current vacation state — what the
|
||||
// real backend does.
|
||||
...settingsResponse.vacation,
|
||||
...(msg.enabled !== undefined ? { enabled: msg.enabled, is_active: msg.enabled } : {}),
|
||||
...(msg.start !== undefined ? { start: msg.start } : {}),
|
||||
...(msg.end !== undefined ? { end: msg.end } : {}),
|
||||
...(msg.buffer_days !== undefined ? { buffer_days: msg.buffer_days } : {}),
|
||||
...(msg.exempt_task_ids !== undefined ? { exempt_task_ids: msg.exempt_task_ids } : {}),
|
||||
}),
|
||||
"maintenance_supporter/vacation/end_now": () => ({
|
||||
...settingsResponse.vacation, enabled: false, is_active: false,
|
||||
}),
|
||||
"maintenance_supporter/vacation/preview": () => ({ rows: [], window_end: null }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function mount(opts = {}) {
|
||||
const { hass, sent } = mockHass(opts);
|
||||
const el = await fixture<MaintenanceSettingsView>(html`
|
||||
<maintenance-settings-view .hass=${hass} .features=${DEFAULT_FEATURES}></maintenance-settings-view>
|
||||
`);
|
||||
// Wait for _loadSettings to complete — it's kicked off by updated()
|
||||
// which fires after first render.
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
await el.updateComplete;
|
||||
return { el, sent };
|
||||
}
|
||||
|
||||
function vacationSection(el: MaintenanceSettingsView): HTMLElement | null {
|
||||
return el.shadowRoot?.querySelector(".vacation-section") || null;
|
||||
}
|
||||
|
||||
describe("settings-view vacation section", () => {
|
||||
it("renders the vacation section with title and disabled toggle by default", async () => {
|
||||
const { el } = await mount();
|
||||
const section = vacationSection(el);
|
||||
expect(section, "vacation section present").to.exist;
|
||||
const h3 = section!.querySelector("h3");
|
||||
expect(h3?.textContent || "").to.match(/vacation|urlaub/i);
|
||||
const toggle = section!.querySelector<HTMLInputElement>(".vac-toggle input");
|
||||
expect(toggle, "enable toggle present").to.exist;
|
||||
expect(toggle!.checked, "toggle off by default").to.be.false;
|
||||
});
|
||||
|
||||
it("hydrates dates from settings response", async () => {
|
||||
const { el } = await mount({
|
||||
vacationStart: "2099-06-10",
|
||||
vacationEnd: "2099-06-20",
|
||||
});
|
||||
const dateInputs = vacationSection(el)!.querySelectorAll<HTMLInputElement>(".vac-grid input[type=date]");
|
||||
expect(dateInputs.length, "two date inputs").to.equal(2);
|
||||
expect(dateInputs[0].value).to.equal("2099-06-10");
|
||||
expect(dateInputs[1].value).to.equal("2099-06-20");
|
||||
});
|
||||
|
||||
it("shows the active badge when vacation.is_active is true", async () => {
|
||||
const { el } = await mount({
|
||||
vacationActive: true,
|
||||
vacationStart: "2099-06-10",
|
||||
vacationEnd: "2099-06-20",
|
||||
});
|
||||
const badge = vacationSection(el)!.querySelector(".vac-badge.active");
|
||||
expect(badge, "active badge rendered").to.exist;
|
||||
const endNow = vacationSection(el)!.querySelector(".vac-end-now");
|
||||
expect(endNow, "end-now button rendered").to.exist;
|
||||
});
|
||||
|
||||
it("dispatches vacation/update when the enable toggle is clicked", async () => {
|
||||
const { el, sent } = await mount();
|
||||
const toggle = vacationSection(el)!.querySelector<HTMLInputElement>(".vac-toggle input")!;
|
||||
toggle.checked = true;
|
||||
toggle.dispatchEvent(new Event("change"));
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
const update = sent.find(m => m.type === "maintenance_supporter/vacation/update");
|
||||
expect(update, "update message sent").to.exist;
|
||||
expect(update!.enabled, "enabled=true in payload").to.equal(true);
|
||||
});
|
||||
|
||||
it("dispatches vacation/update with new buffer_days when number changes", async () => {
|
||||
const { el, sent } = await mount();
|
||||
const buffer = vacationSection(el)!.querySelectorAll<HTMLInputElement>(".vac-grid input[type=number]")[0];
|
||||
expect(buffer, "buffer input present").to.exist;
|
||||
buffer.value = "7";
|
||||
buffer.dispatchEvent(new Event("change"));
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
const update = sent.find(m => m.type === "maintenance_supporter/vacation/update" && m.buffer_days === 7);
|
||||
expect(update, "update with buffer_days=7 sent").to.exist;
|
||||
});
|
||||
|
||||
it("emits settings-changed when vacation toggles (so the panel re-evaluates the Vacation tab)", async () => {
|
||||
const { el } = await mount();
|
||||
const toggle = vacationSection(el)!.querySelector<HTMLInputElement>(".vac-toggle input")!;
|
||||
toggle.checked = true;
|
||||
const evtPromise = oneEvent(el, "settings-changed");
|
||||
toggle.dispatchEvent(new Event("change"));
|
||||
const evt = await evtPromise;
|
||||
expect(evt.type).to.equal("settings-changed");
|
||||
});
|
||||
|
||||
it("does not show end-now button when vacation is disabled and not stale", async () => {
|
||||
const { el } = await mount();
|
||||
const endNow = vacationSection(el)!.querySelector(".vac-end-now");
|
||||
expect(endNow, "no end-now in default state").to.not.exist;
|
||||
});
|
||||
});
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
/** Lit component tests for <maintenance-storage-section-card>. */
|
||||
|
||||
import { expect, fixture, html } from "@open-wc/testing";
|
||||
import "../components/storage-section-card.js";
|
||||
import type { MaintenanceStorageSectionCard } from "../components/storage-section-card";
|
||||
import { createMockHass } from "./_test-utils.js";
|
||||
|
||||
const SUMMARY = {
|
||||
total_bytes: 13, dedup_savings_bytes: 10, file_count: 3, link_count: 1, document_count: 4,
|
||||
by_object: {
|
||||
objA: { bytes: 10, files: 2, links: 0 },
|
||||
objB: { bytes: 3, files: 1, links: 1 },
|
||||
},
|
||||
};
|
||||
const OBJECTS = [
|
||||
{ entry_id: "e1", object: { id: "objA", name: "Pool Pump" } },
|
||||
{ entry_id: "e2", object: { id: "objB", name: "HVAC" } },
|
||||
];
|
||||
|
||||
async function mount(summary: unknown = SUMMARY, objects: unknown = OBJECTS) {
|
||||
const { hass } = createMockHass({
|
||||
handlers: { "maintenance_supporter/documents/storage": () => summary },
|
||||
});
|
||||
const el = await fixture<MaintenanceStorageSectionCard>(html`
|
||||
<maintenance-storage-section-card .hass=${hass} .objects=${objects}></maintenance-storage-section-card>
|
||||
`);
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
await el.updateComplete;
|
||||
return el;
|
||||
}
|
||||
|
||||
async function expand(el: MaintenanceStorageSectionCard) {
|
||||
el.shadowRoot!.querySelector<HTMLButtonElement>(".toggle")!.click();
|
||||
await el.updateComplete;
|
||||
}
|
||||
|
||||
describe("storage-section-card", () => {
|
||||
it("is collapsed by default and expands on toggle", async () => {
|
||||
const el = await mount();
|
||||
expect(el.shadowRoot!.querySelector(".body"), "collapsed by default").to.not.exist;
|
||||
// the headline total stays visible in the collapsed header
|
||||
expect(el.shadowRoot!.querySelector(".header-summary"), "summary in header").to.exist;
|
||||
await expand(el);
|
||||
expect(el.shadowRoot!.querySelector(".body"), "expands on toggle").to.exist;
|
||||
expect(el.shadowRoot!.querySelectorAll(".obj-row").length).to.equal(2);
|
||||
});
|
||||
|
||||
it("renders total, dedup saving and per-object rows sorted by size", async () => {
|
||||
const el = await mount();
|
||||
await expand(el);
|
||||
const rows = el.shadowRoot!.querySelectorAll(".obj-row");
|
||||
expect(rows.length).to.equal(2);
|
||||
expect(rows[0].querySelector(".obj-name")!.textContent).to.contain("Pool Pump"); // 10 B first
|
||||
expect(rows[1].querySelector(".obj-name")!.textContent).to.contain("HVAC");
|
||||
expect(el.shadowRoot!.querySelector(".stat-value.saved"), "dedup saving shown").to.exist;
|
||||
});
|
||||
|
||||
it("self-hides when there are no documents", async () => {
|
||||
const el = await mount({ ...SUMMARY, document_count: 0, by_object: {} });
|
||||
expect(el.shadowRoot!.querySelector("ha-card"), "hidden when empty").to.not.exist;
|
||||
});
|
||||
|
||||
it("navigates to an object when its row is clicked", async () => {
|
||||
const el = await mount();
|
||||
await expand(el);
|
||||
let openedEntry = "";
|
||||
el.addEventListener("open-object", (e) => { openedEntry = (e as CustomEvent).detail.entry_id; });
|
||||
const row = el.shadowRoot!.querySelector<HTMLElement>(".obj-row.clickable");
|
||||
expect(row, "object row is clickable").to.exist;
|
||||
expect(row!.getAttribute("role")).to.equal("button");
|
||||
row!.click();
|
||||
expect(openedEntry, "open-object dispatched with the entry id").to.equal("e1"); // Pool Pump / objA, largest
|
||||
});
|
||||
|
||||
it("searches documents and renders results with the object name", async () => {
|
||||
const { hass } = createMockHass({
|
||||
handlers: {
|
||||
"maintenance_supporter/documents/storage": () => SUMMARY,
|
||||
"maintenance_supporter/documents/search": () => ({
|
||||
results: [
|
||||
{ id: "d1", entry_id: "e1", object_name: "Pool Pump", kind: "file", title: "Manual", filename: "m.pdf", size: 100, tags: ["manual"] },
|
||||
],
|
||||
}),
|
||||
},
|
||||
});
|
||||
const el = await fixture<MaintenanceStorageSectionCard>(html`
|
||||
<maintenance-storage-section-card .hass=${hass} .objects=${OBJECTS}></maintenance-storage-section-card>
|
||||
`);
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
await el.updateComplete;
|
||||
await expand(el);
|
||||
|
||||
const input = el.shadowRoot!.querySelector<HTMLInputElement>(".doc-search input")!;
|
||||
input.value = "manual";
|
||||
input.dispatchEvent(new Event("input"));
|
||||
await new Promise((r) => setTimeout(r, 320)); // debounce (250 ms)
|
||||
await el.updateComplete;
|
||||
|
||||
const results = el.shadowRoot!.querySelectorAll(".result-row");
|
||||
expect(results.length).to.equal(1);
|
||||
expect(results[0].querySelector(".result-title")!.textContent).to.contain("Manual");
|
||||
expect(results[0].querySelector(".result-obj")!.textContent).to.contain("Pool Pump");
|
||||
});
|
||||
|
||||
it("falls back to a short id and stays non-clickable when the object is unknown", async () => {
|
||||
const el = await mount(
|
||||
{
|
||||
...SUMMARY, document_count: 1, file_count: 1, link_count: 0,
|
||||
by_object: { "0123456789abcdef": { bytes: 5, files: 1, links: 0 } },
|
||||
},
|
||||
[],
|
||||
);
|
||||
await expand(el);
|
||||
expect(el.shadowRoot!.querySelector(".obj-name")!.textContent!.trim()).to.equal("01234567");
|
||||
expect(el.shadowRoot!.querySelector(".obj-row.clickable"), "unknown object not clickable").to.not.exist;
|
||||
});
|
||||
});
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
/** Tests for the extracted task-detail renderers (renderers/task-detail).
|
||||
*
|
||||
* The cluster moved out of maintenance-panel.ts as free functions + a
|
||||
* TaskDetailContext of ~20 panel-owned callbacks. These tests pin the
|
||||
* behaviour the extraction must preserve:
|
||||
* - header renders name / object breadcrumb / status chip / actions
|
||||
* - Complete / Skip route to the panel callbacks with the task
|
||||
* - operator mode hides archive + the more-menu (read-only surface)
|
||||
* - the more-menu items (edit/duplicate/reset/snooze/delete) fire callbacks
|
||||
* - tab bar switches via setActiveTab; history tab renders the timeline
|
||||
* - KPI bar shows warning days + currency; user badge resolves names
|
||||
*/
|
||||
|
||||
import { expect } from "@open-wc/testing";
|
||||
import { render } from "lit";
|
||||
import {
|
||||
renderTaskDetail,
|
||||
renderUserBadge,
|
||||
type TaskDetailContext,
|
||||
} from "../renderers/task-detail.js";
|
||||
import type { MaintenanceTask } from "../types";
|
||||
import { createMockHass } from "./_test-utils.js";
|
||||
|
||||
function task(overrides: Record<string, unknown> = {}): MaintenanceTask {
|
||||
return {
|
||||
id: "t1",
|
||||
name: "Filter Wechsel",
|
||||
type: "cleaning",
|
||||
enabled: true,
|
||||
status: "due_soon",
|
||||
schedule_type: "time_based",
|
||||
interval_days: 30,
|
||||
warning_days: 7,
|
||||
days_until_due: 3,
|
||||
next_due: "2026-07-10",
|
||||
last_performed: "2026-06-10",
|
||||
times_performed: 2,
|
||||
total_cost: 50,
|
||||
average_duration: 20,
|
||||
history: [],
|
||||
checklist: [],
|
||||
is_done: false,
|
||||
archived: false,
|
||||
trigger_active: false,
|
||||
...overrides,
|
||||
} as unknown as MaintenanceTask;
|
||||
}
|
||||
|
||||
function ctx(overrides: Partial<TaskDetailContext> = {}): TaskDetailContext {
|
||||
const { hass } = createMockHass({
|
||||
handler: () => ({ documents: [] }),
|
||||
});
|
||||
return {
|
||||
lang: "en",
|
||||
hass: hass as TaskDetailContext["hass"],
|
||||
entryId: "entry1",
|
||||
taskId: "t1",
|
||||
objectName: "Pool Pump",
|
||||
objectDocUrl: null,
|
||||
isOperator: false,
|
||||
actionLoading: false,
|
||||
moreMenuOpen: false,
|
||||
activeTab: "overview",
|
||||
features: {
|
||||
adaptive: false, predictions: false, seasonal: false,
|
||||
environmental: false, budget: false, groups: false,
|
||||
checklists: false, schedule_time: false, completion_actions: false,
|
||||
},
|
||||
currencySymbol: "€",
|
||||
collapsedSections: new Set(),
|
||||
costDurationToggle: "both",
|
||||
suggestionDismissed: false,
|
||||
sparkline: {
|
||||
lang: "en",
|
||||
detailStatsData: new Map(),
|
||||
hasStatsService: false,
|
||||
isCounterEntity: () => false,
|
||||
rangeDays: 30,
|
||||
setRangeDays: () => undefined,
|
||||
hideOutliers: false,
|
||||
setHideOutliers: () => undefined,
|
||||
},
|
||||
history: {
|
||||
lang: "en",
|
||||
hass: hass as TaskDetailContext["hass"],
|
||||
filter: null,
|
||||
search: "",
|
||||
currencySymbol: "€",
|
||||
setFilter: () => undefined,
|
||||
setSearch: () => undefined,
|
||||
openEdit: () => undefined,
|
||||
},
|
||||
getUserName: () => null,
|
||||
setActiveTab: () => undefined,
|
||||
toggleSection: () => undefined,
|
||||
setCostDurationToggle: () => undefined,
|
||||
showTaskView: () => undefined,
|
||||
showObject: () => undefined,
|
||||
toggleMoreMenu: () => undefined,
|
||||
closeMoreMenu: () => undefined,
|
||||
openEdit: () => undefined,
|
||||
openComplete: () => undefined,
|
||||
promptSkip: () => undefined,
|
||||
toggleArchive: () => undefined,
|
||||
openQr: () => undefined,
|
||||
duplicateTask: () => undefined,
|
||||
promptReset: () => undefined,
|
||||
snoozeTask: () => undefined,
|
||||
printWorksheet: () => undefined,
|
||||
deleteTask: () => undefined,
|
||||
applySuggestion: () => undefined,
|
||||
reanalyze: () => undefined,
|
||||
dismissSuggestion: () => undefined,
|
||||
openSeasonalOverrides: () => undefined,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function mount(t: MaintenanceTask, c: TaskDetailContext): HTMLElement {
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
render(renderTaskDetail(t, c), host);
|
||||
return host;
|
||||
}
|
||||
|
||||
describe("task-detail renderer", () => {
|
||||
afterEach(() => {
|
||||
document.body.querySelectorAll("div").forEach((el) => {
|
||||
if (el.parentElement === document.body) el.remove();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders header with task name, object breadcrumb, and status chip", () => {
|
||||
const host = mount(task(), ctx());
|
||||
expect(host.querySelector(".task-name-breadcrumb")!.textContent).to.include("Filter Wechsel");
|
||||
expect(host.querySelector(".object-name-breadcrumb")!.textContent).to.include("Pool Pump");
|
||||
const chip = host.querySelector(".status-chip")!;
|
||||
expect(chip.classList.contains("warning")).to.be.true;
|
||||
});
|
||||
|
||||
it("Complete routes to openComplete with the task; Skip to promptSkip", () => {
|
||||
let completed: MaintenanceTask | null = null;
|
||||
let skipped = 0;
|
||||
const host = mount(task(), ctx({
|
||||
openComplete: (tk) => { completed = tk; },
|
||||
promptSkip: () => { skipped++; },
|
||||
}));
|
||||
const buttons = [...host.querySelectorAll(".task-header-actions ha-button")];
|
||||
(buttons[0] as HTMLElement).click(); // Complete (filled)
|
||||
(buttons[1] as HTMLElement).click(); // Skip
|
||||
expect(completed).to.not.be.null;
|
||||
expect(completed!.name).to.equal("Filter Wechsel");
|
||||
expect(skipped).to.equal(1);
|
||||
});
|
||||
|
||||
it("operator mode hides archive button and the more-menu", () => {
|
||||
const host = mount(task(), ctx({ isOperator: true }));
|
||||
expect(host.querySelector(".more-menu-wrapper")).to.be.null;
|
||||
// Only Complete / Skip / QR remain.
|
||||
const labels = [...host.querySelectorAll(".task-header-actions ha-button")]
|
||||
.map((b) => b.textContent || "");
|
||||
expect(labels.some((l) => /archive/i.test(l))).to.be.false;
|
||||
});
|
||||
|
||||
it("open more-menu lists edit/duplicate/reset/snooze/worksheet/delete and fires callbacks", () => {
|
||||
const calls: string[] = [];
|
||||
const host = mount(task(), ctx({
|
||||
moreMenuOpen: true,
|
||||
closeMoreMenu: () => calls.push("close"),
|
||||
deleteTask: () => calls.push("delete"),
|
||||
snoozeTask: () => calls.push("snooze"),
|
||||
printWorksheet: () => calls.push("worksheet"),
|
||||
}));
|
||||
const items = [...host.querySelectorAll(".popup-menu-item")];
|
||||
expect(items.length).to.equal(6);
|
||||
(items[3] as HTMLElement).click(); // snooze
|
||||
(items[4] as HTMLElement).click(); // work sheet (v2.21)
|
||||
(items[5] as HTMLElement).click(); // delete (danger)
|
||||
expect(calls).to.deep.equal(["close", "snooze", "close", "worksheet", "close", "delete"]);
|
||||
});
|
||||
|
||||
it("tab bar switches via setActiveTab; history tab renders the timeline", () => {
|
||||
let tab = "";
|
||||
const host = mount(task(), ctx({ setActiveTab: (t2) => { tab = t2; } }));
|
||||
const tabs = [...host.querySelectorAll(".tab-bar .tab")];
|
||||
(tabs[1] as HTMLElement).click();
|
||||
expect(tab).to.equal("history");
|
||||
|
||||
const host2 = mount(task({
|
||||
history: [{ timestamp: "2026-06-10T10:00:00+00:00", type: "completed", notes: "done" }],
|
||||
}), ctx({ activeTab: "history" }));
|
||||
expect(host2.querySelector(".history-timeline")).to.not.be.null;
|
||||
expect(host2.querySelector(".kpi-bar")).to.be.null;
|
||||
});
|
||||
|
||||
it("KPI bar shows warning days and currency symbol", () => {
|
||||
const host = mount(task(), ctx({ currencySymbol: "$" }));
|
||||
const kpi = host.querySelector(".kpi-bar")!;
|
||||
expect(kpi.textContent).to.include("7");
|
||||
expect(kpi.textContent).to.include("$");
|
||||
});
|
||||
|
||||
it("renderUserBadge resolves the name (and hides when unknown)", () => {
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
render(renderUserBadge(task({ responsible_user_id: "u1" }) , (id) => (id === "u1" ? "Ingmar" : null)), host);
|
||||
expect(host.textContent).to.include("Ingmar");
|
||||
render(renderUserBadge(task({ responsible_user_id: "u2" }), () => null), host);
|
||||
expect(host.textContent!.trim()).to.equal("");
|
||||
});
|
||||
});
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Lit component tests for the Phase 4 calendar recurrence kinds in
|
||||
* <maintenance-task-dialog>: weekdays / nth_weekday / day_of_month.
|
||||
*
|
||||
* Pins the UI side of the WS contract (backend: test_schedule.py +
|
||||
* test_ws_roundtrip.py): hydration from the nested `schedule`, the outgoing
|
||||
* `schedule` payload shape, and that the per-kind field groups render.
|
||||
*/
|
||||
|
||||
import { expect, fixture, html } from "@open-wc/testing";
|
||||
import "../components/task-dialog.js";
|
||||
import type { MaintenanceTaskDialog } from "../components/task-dialog";
|
||||
import { type SentMessage, createMockHass } from "./_test-utils.js";
|
||||
|
||||
async function mountDialog(): Promise<{ el: MaintenanceTaskDialog; sent: SentMessage[] }> {
|
||||
const { hass, sent } = createMockHass({
|
||||
handlers: {
|
||||
"maintenance_supporter/task/create": () => ({ task_id: "new1" }),
|
||||
"maintenance_supporter/task/update": () => ({}),
|
||||
},
|
||||
});
|
||||
const el = await fixture<MaintenanceTaskDialog>(html`
|
||||
<maintenance-task-dialog .hass=${hass}></maintenance-task-dialog>
|
||||
`);
|
||||
await el.updateComplete;
|
||||
return { el, sent };
|
||||
}
|
||||
|
||||
describe("task-dialog calendar kinds (Phase 4)", () => {
|
||||
it("hydrates nth_weekday from the nested schedule on openEdit", async () => {
|
||||
const { el } = await mountDialog();
|
||||
await el.openEdit("e", {
|
||||
id: "t1", name: "Smoke alarm", type: "custom",
|
||||
schedule_type: "nth_weekday", warning_days: 7, enabled: true,
|
||||
schedule: { kind: "nth_weekday", nth: 1, weekday: 5 },
|
||||
} as any);
|
||||
await el.updateComplete;
|
||||
expect((el as any)._scheduleType).to.equal("nth_weekday");
|
||||
expect((el as any)._nth).to.equal("1");
|
||||
expect((el as any)._nthWeekday).to.equal("5");
|
||||
});
|
||||
|
||||
it("hydrates weekdays from the nested schedule", async () => {
|
||||
const { el } = await mountDialog();
|
||||
await el.openEdit("e", {
|
||||
id: "t1", name: "Floors", type: "custom",
|
||||
schedule_type: "weekdays", warning_days: 7, enabled: true,
|
||||
schedule: { kind: "weekdays", weekdays: [0, 3] },
|
||||
} as any);
|
||||
await el.updateComplete;
|
||||
expect((el as any)._scheduleType).to.equal("weekdays");
|
||||
expect((el as any)._weekdays).to.deep.equal([0, 3]);
|
||||
});
|
||||
|
||||
it("create sends the nested schedule for nth_weekday", async () => {
|
||||
const { el, sent } = await mountDialog();
|
||||
await el.openCreate("e");
|
||||
(el as any)._name = "Smoke alarm";
|
||||
(el as any)._scheduleType = "nth_weekday";
|
||||
(el as any)._nth = "1";
|
||||
(el as any)._nthWeekday = "5";
|
||||
await (el as any)._save();
|
||||
const msg = sent.find((m) => m.type === "maintenance_supporter/task/create") as any;
|
||||
expect(msg, "create message sent").to.exist;
|
||||
expect(msg.schedule).to.deep.equal({ kind: "nth_weekday", nth: 1, weekday: 5 });
|
||||
});
|
||||
|
||||
it("create sends a sorted weekdays schedule", async () => {
|
||||
const { el, sent } = await mountDialog();
|
||||
await el.openCreate("e");
|
||||
(el as any)._name = "Floors";
|
||||
(el as any)._scheduleType = "weekdays";
|
||||
(el as any)._weekdays = [3, 0];
|
||||
await (el as any)._save();
|
||||
const msg = sent.find((m) => m.type === "maintenance_supporter/task/create") as any;
|
||||
expect(msg.schedule).to.deep.equal({ kind: "weekdays", weekdays: [0, 3] });
|
||||
});
|
||||
|
||||
it("create sends day_of_month", async () => {
|
||||
const { el, sent } = await mountDialog();
|
||||
await el.openCreate("e");
|
||||
(el as any)._name = "Rent";
|
||||
(el as any)._scheduleType = "day_of_month";
|
||||
(el as any)._domDay = "15";
|
||||
await (el as any)._save();
|
||||
const msg = sent.find((m) => m.type === "maintenance_supporter/task/create") as any;
|
||||
expect(msg.schedule).to.deep.equal({ kind: "day_of_month", day: 15 });
|
||||
});
|
||||
|
||||
it("renders 7 weekday chips for the weekdays kind", async () => {
|
||||
const { el } = await mountDialog();
|
||||
await el.openCreate("e");
|
||||
(el as any)._scheduleType = "weekdays";
|
||||
await el.updateComplete;
|
||||
const chips = el.shadowRoot!.querySelectorAll(".weekday-chip");
|
||||
expect(chips.length).to.equal(7);
|
||||
});
|
||||
|
||||
it("(#83) create sends last business day with -2 offset", async () => {
|
||||
const { el, sent } = await mountDialog();
|
||||
(el as any)._name = "EOM";
|
||||
(el as any)._scheduleType = "day_of_month";
|
||||
(el as any)._domLastDay = true;
|
||||
(el as any)._domBusiness = true;
|
||||
(el as any)._calOffset = "-2";
|
||||
await (el as any)._save();
|
||||
const msg = sent.find((m: any) => m.type === "maintenance_supporter/task/create");
|
||||
expect(msg.schedule).to.deep.equal({
|
||||
kind: "day_of_month", day: -1, business: true, offset: -2,
|
||||
});
|
||||
});
|
||||
|
||||
it("(#83) edit hydrates last-day/business/offset from the stored schedule", async () => {
|
||||
const { el } = await mountDialog();
|
||||
(el as any).openEdit("e1", {
|
||||
id: "t1", name: "EOM", type: "custom", enabled: true,
|
||||
warning_days: 7, history: [],
|
||||
schedule: { kind: "day_of_month", day: -1, business: true, offset: -2 },
|
||||
schedule_type: "day_of_month",
|
||||
});
|
||||
await el.updateComplete;
|
||||
expect((el as any)._domLastDay).to.equal(true);
|
||||
expect((el as any)._domBusiness).to.equal(true);
|
||||
expect((el as any)._calOffset).to.equal("-2");
|
||||
});
|
||||
});
|
||||
+280
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* Lit component tests for the v1.3.0 completion-actions sections of
|
||||
* <maintenance-task-dialog>: on_complete_action + quick_complete_defaults.
|
||||
*
|
||||
* The two sections are gated behind `completionActionsEnabled`. Save-payload
|
||||
* shape is what the WS contract pins on the backend (test_completion_actions.py
|
||||
* + test_ws_roundtrip.py); these tests pin the UI side: gating, hydration of
|
||||
* existing values, and outgoing payload shape.
|
||||
*/
|
||||
|
||||
import { expect, fixture, html } from "@open-wc/testing";
|
||||
import "../components/task-dialog.js";
|
||||
import type { MaintenanceTaskDialog } from "../components/task-dialog";
|
||||
import {
|
||||
type CreateMockHassResult,
|
||||
type SentMessage,
|
||||
type ServiceCall,
|
||||
createMockHass,
|
||||
} from "./_test-utils.js";
|
||||
|
||||
// v1.3.1: minimal service registry so the schema-driven data form can
|
||||
// resolve `light.toggle` to a known field set.
|
||||
const SERVICES_FIXTURE = {
|
||||
light: {
|
||||
toggle: {
|
||||
fields: {
|
||||
brightness: { selector: { number: { min: 0, max: 255 } }, required: false },
|
||||
transition: { selector: { number: { min: 0, max: 60 } }, required: false },
|
||||
},
|
||||
},
|
||||
turn_on: {
|
||||
fields: {
|
||||
brightness: { selector: { number: { min: 0, max: 255 } }, required: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
button: { press: {} }, // no fields → fallback JSON textfield
|
||||
};
|
||||
|
||||
function mockHass(): CreateMockHassResult {
|
||||
return createMockHass({
|
||||
services: SERVICES_FIXTURE,
|
||||
handlers: {
|
||||
"maintenance_supporter/task/create": () => ({ task_id: "newtask123" }),
|
||||
"maintenance_supporter/task/update": () => ({}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function mountDialog(opts: { completionActions?: boolean } = {}): Promise<{
|
||||
el: MaintenanceTaskDialog;
|
||||
sent: SentMessage[];
|
||||
serviceCalls: ServiceCall[];
|
||||
}> {
|
||||
const { hass, sent, serviceCalls } = mockHass();
|
||||
const el = await fixture<MaintenanceTaskDialog>(html`
|
||||
<maintenance-task-dialog
|
||||
.hass=${hass}
|
||||
?completion-actions-enabled=${opts.completionActions ?? false}
|
||||
></maintenance-task-dialog>
|
||||
`);
|
||||
await el.updateComplete;
|
||||
return { el, sent, serviceCalls };
|
||||
}
|
||||
|
||||
function caSections(el: MaintenanceTaskDialog): NodeListOf<HTMLElement> {
|
||||
return el.shadowRoot!.querySelectorAll<HTMLElement>("details.ca-section");
|
||||
}
|
||||
|
||||
describe("task-dialog completion-actions sections", () => {
|
||||
it("hides both sections when feature flag is off", async () => {
|
||||
const { el } = await mountDialog({ completionActions: false });
|
||||
await el.openCreate("entry_x");
|
||||
await el.updateComplete;
|
||||
expect(caSections(el).length, "no .ca-section when gated off").to.equal(0);
|
||||
});
|
||||
|
||||
it("renders both sections when feature flag is on", async () => {
|
||||
const { el } = await mountDialog({ completionActions: true });
|
||||
await el.openCreate("entry_x");
|
||||
await el.updateComplete;
|
||||
const sections = caSections(el);
|
||||
expect(sections.length, "two collapsible sections present").to.equal(2);
|
||||
expect(sections[0].querySelector("summary")?.textContent || "")
|
||||
.to.match(/action|aktion/i);
|
||||
expect(sections[1].querySelector("summary")?.textContent || "")
|
||||
.to.match(/quick|schnell/i);
|
||||
});
|
||||
|
||||
it("hydrates on_complete_action from an existing task on openEdit", async () => {
|
||||
const { el } = await mountDialog({ completionActions: true });
|
||||
await el.openEdit("entry_x", {
|
||||
id: "t1",
|
||||
name: "Edit hydration",
|
||||
type: "custom",
|
||||
schedule_type: "time_based",
|
||||
interval_days: 30,
|
||||
warning_days: 7,
|
||||
enabled: true,
|
||||
on_complete_action: {
|
||||
service: "light.turn_on",
|
||||
target: { entity_id: "light.workshop" },
|
||||
data: { brightness: 200 },
|
||||
},
|
||||
quick_complete_defaults: {
|
||||
notes: "Quick note",
|
||||
cost: 4.5,
|
||||
duration: 10,
|
||||
feedback: "needed",
|
||||
},
|
||||
} as any);
|
||||
await el.updateComplete;
|
||||
|
||||
// v1.3.1: service field is now an <ha-service-picker> (not a textfield).
|
||||
const servicePicker = el.shadowRoot!
|
||||
.querySelector<HTMLElement & { value: string }>(
|
||||
"details.ca-section ha-service-picker",
|
||||
);
|
||||
expect(servicePicker?.value, "service picker hydrated").to.equal("light.turn_on");
|
||||
|
||||
// Internal state pins the parsed data dict — ha-form gets it via .data prop.
|
||||
expect((el as any)._actionData.brightness, "data hydrated").to.equal(200);
|
||||
|
||||
// Quick-complete fields are in the second details panel.
|
||||
// v2.3.x: ha-textfield → ms-textfield to dodge HA's lazy-load (issues #46/#50).
|
||||
const sections = caSections(el);
|
||||
const qcInputs = sections[1].querySelectorAll<HTMLElement & { value: string }>(
|
||||
"ms-textfield",
|
||||
);
|
||||
expect(qcInputs[0]?.value, "qc notes").to.equal("Quick note");
|
||||
expect(qcInputs[1]?.value, "qc cost").to.equal("4.5");
|
||||
expect(qcInputs[2]?.value, "qc duration").to.equal("10");
|
||||
|
||||
const qcSelect = sections[1].querySelector<HTMLSelectElement>("select.qc-feedback");
|
||||
expect(qcSelect?.value, "qc feedback").to.equal("needed");
|
||||
});
|
||||
|
||||
it("Test button validates without firing the action (no callService side effect)", async () => {
|
||||
// User feedback: *"setzt Test auch den zustand? dann würde beim
|
||||
// testen bereits so getan als ob eine Wartung ausgeführt wurde —
|
||||
// das ist nicht gut"*. The Test button must validate the
|
||||
// configuration without actually firing the service-call (which
|
||||
// for input_button.press / counter.increment / etc would have real
|
||||
// side-effects, e.g. resetting a vacuum's dirty-time sensor).
|
||||
const { hass, serviceCalls } = createMockHass({ services: SERVICES_FIXTURE });
|
||||
(hass as any).states = { "light.workshop": { entity_id: "light.workshop", state: "off" } };
|
||||
const el = await fixture<MaintenanceTaskDialog>(html`
|
||||
<maintenance-task-dialog
|
||||
.hass=${hass}
|
||||
?completion-actions-enabled=${true}
|
||||
></maintenance-task-dialog>
|
||||
`);
|
||||
await el.openCreate("entry_x");
|
||||
(el as any)._actionService = "light.toggle";
|
||||
(el as any)._actionTargetEntity = "light.workshop";
|
||||
await el.updateComplete;
|
||||
|
||||
const testBtn = el.shadowRoot!.querySelector<HTMLButtonElement>(
|
||||
"details.ca-section .ca-test-row button",
|
||||
);
|
||||
testBtn!.click();
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
|
||||
expect(serviceCalls.length, "no real service-call dispatched (validate-only)").to.equal(0);
|
||||
expect((el as any)._actionTestResult).to.equal("ok");
|
||||
});
|
||||
|
||||
it("Test button blocks save + shows error on service/entity domain-mismatch", async () => {
|
||||
// Issue #50 follow-up: user picked button.press service + input_button.*
|
||||
// entity. HA's service-call would silently log "Referenced entities ...
|
||||
// missing or not currently available" — looks like success but never
|
||||
// fires. Up-front check catches the mismatch.
|
||||
const { el } = await mountDialog({ completionActions: true });
|
||||
await el.openCreate("entry_x");
|
||||
(el as any)._actionService = "button.press";
|
||||
(el as any)._actionTargetEntity = "input_button.foo";
|
||||
await el.updateComplete;
|
||||
|
||||
const testBtn = el.shadowRoot!.querySelector<HTMLButtonElement>(
|
||||
"details.ca-section .ca-test-row button",
|
||||
);
|
||||
testBtn!.click();
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
await el.updateComplete;
|
||||
|
||||
expect((el as any)._actionTestResult).to.equal("error");
|
||||
const errMsg = (el as any)._actionTestError as string;
|
||||
expect(errMsg, "error message names both domains")
|
||||
.to.match(/button\.\*.*input_button\.\*|input_button\.\*.*button\.\*/);
|
||||
});
|
||||
|
||||
it("Test button allows cross-domain services (homeassistant.turn_on, scene.*)", async () => {
|
||||
const { hass } = createMockHass({
|
||||
services: { homeassistant: { turn_on: {} }, light: { toggle: {} } },
|
||||
});
|
||||
// Override hass.states so the entity-exists check passes
|
||||
(hass as any).states = { "light.kitchen": { entity_id: "light.kitchen", state: "off" } };
|
||||
const el = await fixture<MaintenanceTaskDialog>(html`
|
||||
<maintenance-task-dialog
|
||||
.hass=${hass}
|
||||
?completion-actions-enabled=${true}
|
||||
></maintenance-task-dialog>
|
||||
`);
|
||||
await el.openCreate("entry_x");
|
||||
(el as any)._actionService = "homeassistant.turn_on";
|
||||
(el as any)._actionTargetEntity = "light.kitchen";
|
||||
await el.updateComplete;
|
||||
|
||||
const testBtn = el.shadowRoot!.querySelector<HTMLButtonElement>(
|
||||
"details.ca-section .ca-test-row button",
|
||||
);
|
||||
testBtn!.click();
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
|
||||
expect((el as any)._actionTestResult).to.equal("ok");
|
||||
expect((el as any)._actionTestError).to.equal("");
|
||||
});
|
||||
|
||||
it("Test button errors when service is not registered in hass.services", async () => {
|
||||
const { el } = await mountDialog({ completionActions: true });
|
||||
await el.openCreate("entry_x");
|
||||
(el as any)._actionService = "imaginary.nope"; // not in SERVICES_FIXTURE
|
||||
await el.updateComplete;
|
||||
const testBtn = el.shadowRoot!.querySelector<HTMLButtonElement>(
|
||||
"details.ca-section .ca-test-row button",
|
||||
);
|
||||
testBtn!.click();
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
expect((el as any)._actionTestResult).to.equal("error");
|
||||
expect((el as any)._actionTestError).to.match(/not registered|nicht/i);
|
||||
});
|
||||
|
||||
it("renders ha-form when the picked service has a schema", async () => {
|
||||
const { el } = await mountDialog({ completionActions: true });
|
||||
await el.openCreate("entry_x");
|
||||
(el as any)._actionService = "light.toggle";
|
||||
await el.updateComplete;
|
||||
const actionSection = caSections(el)[0]!;
|
||||
// Two ha-form elements expected: the entity picker (always) + the
|
||||
// data-form (only when service has a schema, like light.toggle does).
|
||||
expect(
|
||||
actionSection.querySelector("ha-form.ca-data-form"),
|
||||
"data ha-form rendered for schemaed service",
|
||||
).to.exist;
|
||||
expect(
|
||||
actionSection.querySelector("ms-textfield"),
|
||||
"no JSON fallback textfield when schema present",
|
||||
).to.not.exist;
|
||||
});
|
||||
|
||||
it("falls back to JSON textfield when the service has no schema", async () => {
|
||||
const { el } = await mountDialog({ completionActions: true });
|
||||
await el.openCreate("entry_x");
|
||||
(el as any)._actionService = "button.press"; // services.button.press has no fields
|
||||
await el.updateComplete;
|
||||
const actionSection = caSections(el)[0]!;
|
||||
// v2.3.x: there's now an entity-picker ha-form (always present) PLUS
|
||||
// the optional data-form ha-form (only when service has a schema).
|
||||
// Disambiguate via the .ca-data-form class — the bare ha-form is the
|
||||
// entity picker and is always present.
|
||||
expect(
|
||||
actionSection.querySelector("ha-form.ca-data-form"),
|
||||
"no DATA ha-form when service has no fields",
|
||||
).to.not.exist;
|
||||
expect(
|
||||
actionSection.querySelector("ms-textfield"),
|
||||
"JSON fallback textfield rendered (now ms-textfield)",
|
||||
).to.exist;
|
||||
});
|
||||
|
||||
it("Test button is disabled when service is empty", async () => {
|
||||
const { el } = await mountDialog({ completionActions: true });
|
||||
await el.openCreate("entry_x");
|
||||
await el.updateComplete;
|
||||
const btn = el.shadowRoot!.querySelector<HTMLButtonElement>(
|
||||
"details.ca-section .ca-test-row button",
|
||||
);
|
||||
expect(btn?.disabled, "test button disabled with no service").to.be.true;
|
||||
});
|
||||
});
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* <maintenance-task-dialog>: the compound trigger editor (panel↔config-flow
|
||||
* parity). Hydration from a compound trigger_config into per-condition drafts,
|
||||
* and the outgoing WS payload building the {type:compound, compound_logic,
|
||||
* conditions[]} shape the backend expects.
|
||||
*/
|
||||
|
||||
import { expect, fixture, html } from "@open-wc/testing";
|
||||
import "../components/task-dialog.js";
|
||||
import type { MaintenanceTaskDialog } from "../components/task-dialog";
|
||||
import { type SentMessage, createMockHass } from "./_test-utils.js";
|
||||
|
||||
async function mountDialog(): Promise<{ el: MaintenanceTaskDialog; sent: SentMessage[] }> {
|
||||
const { hass, sent } = createMockHass({
|
||||
handlers: {
|
||||
"maintenance_supporter/task/create": () => ({ task_id: "new1" }),
|
||||
"maintenance_supporter/task/update": () => ({}),
|
||||
},
|
||||
});
|
||||
const el = await fixture<MaintenanceTaskDialog>(html`
|
||||
<maintenance-task-dialog .hass=${hass}></maintenance-task-dialog>
|
||||
`);
|
||||
await el.updateComplete;
|
||||
return { el, sent };
|
||||
}
|
||||
|
||||
describe("task-dialog compound trigger editor (parity)", () => {
|
||||
it("hydrates compound_logic + conditions from trigger_config", async () => {
|
||||
const { el } = await mountDialog();
|
||||
await el.openEdit("e", {
|
||||
id: "t1", name: "AC service", type: "custom",
|
||||
schedule_type: "sensor_based", warning_days: 7, enabled: true,
|
||||
trigger_config: {
|
||||
type: "compound",
|
||||
compound_logic: "OR",
|
||||
conditions: [
|
||||
{ entity_id: "sensor.hours", entity_ids: ["sensor.hours"], type: "runtime", trigger_runtime_hours: 500 },
|
||||
{ entity_id: "sensor.dust", entity_ids: ["sensor.dust"], type: "threshold", trigger_above: 80 },
|
||||
],
|
||||
},
|
||||
} as any);
|
||||
await el.updateComplete;
|
||||
expect((el as any)._triggerType).to.equal("compound");
|
||||
expect((el as any)._compoundLogic).to.equal("OR");
|
||||
const conds = (el as any)._compoundConditions;
|
||||
expect(conds).to.have.length(2);
|
||||
expect(conds[0].type).to.equal("runtime");
|
||||
expect(conds[0].runtimeHours).to.equal("500");
|
||||
expect(conds[0].entityIds).to.equal("sensor.hours");
|
||||
expect(conds[1].type).to.equal("threshold");
|
||||
expect(conds[1].above).to.equal("80");
|
||||
});
|
||||
|
||||
it("builds a compound trigger_config on save", async () => {
|
||||
const { el, sent } = await mountDialog();
|
||||
await el.openCreate("e");
|
||||
(el as any)._name = "AC service";
|
||||
(el as any)._scheduleType = "sensor_based";
|
||||
(el as any)._triggerType = "compound";
|
||||
(el as any)._compoundLogic = "AND";
|
||||
(el as any)._compoundConditions = [
|
||||
{ entityIds: "sensor.hours", type: "runtime", above: "", below: "", forMinutes: "0",
|
||||
targetValue: "", deltaMode: false, fromState: "", toState: "", targetChanges: "", runtimeHours: "500" },
|
||||
{ entityIds: "sensor.dust, sensor.dust2", type: "threshold", above: "80", below: "", forMinutes: "0",
|
||||
targetValue: "", deltaMode: false, fromState: "", toState: "", targetChanges: "", runtimeHours: "" },
|
||||
];
|
||||
await (el as any)._save();
|
||||
const msg = sent.find((m) => m.type === "maintenance_supporter/task/create") as any;
|
||||
expect(msg, "create message sent").to.exist;
|
||||
const tc = msg.trigger_config;
|
||||
expect(tc.type).to.equal("compound");
|
||||
expect(tc.compound_logic).to.equal("AND");
|
||||
expect(tc.conditions).to.have.length(2);
|
||||
expect(tc.conditions[0]).to.deep.include({ type: "runtime", trigger_runtime_hours: 500 });
|
||||
expect(tc.conditions[0].entity_ids).to.deep.equal(["sensor.hours"]);
|
||||
expect(tc.conditions[1]).to.deep.include({ type: "threshold", trigger_above: 80 });
|
||||
expect(tc.conditions[1].entity_ids).to.deep.equal(["sensor.dust", "sensor.dust2"]);
|
||||
});
|
||||
|
||||
it("drops conditions with no entity, and clears the trigger if all empty on edit", async () => {
|
||||
const { el, sent } = await mountDialog();
|
||||
await el.openEdit("e", {
|
||||
id: "t1", name: "x", type: "custom", schedule_type: "sensor_based",
|
||||
warning_days: 7, enabled: true,
|
||||
trigger_config: { type: "compound", compound_logic: "AND", conditions: [] },
|
||||
} as any);
|
||||
(el as any)._triggerType = "compound";
|
||||
(el as any)._compoundConditions = [
|
||||
{ entityIds: " ", type: "threshold", above: "1", below: "", forMinutes: "0",
|
||||
targetValue: "", deltaMode: false, fromState: "", toState: "", targetChanges: "", runtimeHours: "" },
|
||||
];
|
||||
await (el as any)._save();
|
||||
const msg = sent.find((m) => m.type === "maintenance_supporter/task/update") as any;
|
||||
expect(msg, "update message sent").to.exist;
|
||||
expect(msg.trigger_config).to.equal(null);
|
||||
});
|
||||
});
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Lit component test for #42 — `interval_days` hydration on edit.
|
||||
*
|
||||
* Repro: a sensor_based task whose user has cleared the optional safety
|
||||
* interval is persisted as `interval_days: null` on the backend. Before
|
||||
* the fix, opening the edit dialog re-hydrated the field as "30" because
|
||||
* of the `?.toString() || "30"` fallback in openEdit, silently restoring
|
||||
* a value the user had explicitly removed.
|
||||
*
|
||||
* The test pins both branches of the corrected ternary:
|
||||
* - null → empty string
|
||||
* - explicit number → string of that number
|
||||
*/
|
||||
|
||||
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 mountDialog(): Promise<MaintenanceTaskDialog> {
|
||||
const { hass } = createMockHass({
|
||||
handlers: {
|
||||
"maintenance_supporter/task/update": () => ({}),
|
||||
},
|
||||
});
|
||||
const el = await fixture<MaintenanceTaskDialog>(html`
|
||||
<maintenance-task-dialog .hass=${hass}></maintenance-task-dialog>
|
||||
`);
|
||||
await el.updateComplete;
|
||||
return el;
|
||||
}
|
||||
|
||||
describe("task-dialog interval_days hydration (#42 regression)", () => {
|
||||
it("hydrates a cleared safety interval as empty string, not '30'", async () => {
|
||||
const el = await mountDialog();
|
||||
await el.openEdit("entry_x", {
|
||||
id: "t1",
|
||||
name: "Sensor task without safety net",
|
||||
type: "custom",
|
||||
schedule_type: "sensor_based",
|
||||
interval_days: null, // user has cleared the safety interval
|
||||
warning_days: 7,
|
||||
enabled: true,
|
||||
trigger_config: {
|
||||
type: "threshold",
|
||||
entity_id: "sensor.x",
|
||||
trigger_above: 100,
|
||||
},
|
||||
} as any);
|
||||
await el.updateComplete;
|
||||
|
||||
expect((el as any)._intervalDays, "should be empty when persisted as null").to.equal("");
|
||||
});
|
||||
|
||||
it("hydrates an explicit interval value to its string form", async () => {
|
||||
const el = await mountDialog();
|
||||
await el.openEdit("entry_x", {
|
||||
id: "t1",
|
||||
name: "Time-based task with 90-day interval",
|
||||
type: "custom",
|
||||
schedule_type: "time_based",
|
||||
interval_days: 90,
|
||||
warning_days: 7,
|
||||
enabled: true,
|
||||
} as any);
|
||||
await el.updateComplete;
|
||||
|
||||
expect((el as any)._intervalDays, "should preserve the persisted number").to.equal("90");
|
||||
});
|
||||
|
||||
it("hydrates a missing interval_days field as empty string", async () => {
|
||||
// The backend may send the field as undefined when not set on a manual task.
|
||||
const el = await mountDialog();
|
||||
await el.openEdit("entry_x", {
|
||||
id: "t1",
|
||||
name: "Manual task",
|
||||
type: "custom",
|
||||
schedule_type: "manual",
|
||||
warning_days: 7,
|
||||
enabled: true,
|
||||
} as any);
|
||||
await el.updateComplete;
|
||||
|
||||
expect((el as any)._intervalDays, "undefined should hydrate to empty").to.equal("");
|
||||
});
|
||||
});
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* <maintenance-task-dialog>: the auto_complete_on_recovery flag (#53) —
|
||||
* hydration from trigger_config, checkbox rendering, and the outgoing
|
||||
* WS payload (set only when true; absence means off).
|
||||
*/
|
||||
|
||||
import { expect, fixture, html } from "@open-wc/testing";
|
||||
import "../components/task-dialog.js";
|
||||
import type { MaintenanceTaskDialog } from "../components/task-dialog";
|
||||
import { type SentMessage, createMockHass } from "./_test-utils.js";
|
||||
|
||||
async function mountDialog(): Promise<{ el: MaintenanceTaskDialog; sent: SentMessage[] }> {
|
||||
const { hass, sent } = createMockHass({
|
||||
handlers: {
|
||||
"maintenance_supporter/task/create": () => ({ task_id: "new1" }),
|
||||
"maintenance_supporter/task/update": () => ({}),
|
||||
},
|
||||
});
|
||||
const el = await fixture<MaintenanceTaskDialog>(html`
|
||||
<maintenance-task-dialog .hass=${hass}></maintenance-task-dialog>
|
||||
`);
|
||||
await el.updateComplete;
|
||||
return { el, sent };
|
||||
}
|
||||
|
||||
describe("task-dialog auto-complete-on-recovery (#53)", () => {
|
||||
it("hydrates the flag from trigger_config on openEdit", async () => {
|
||||
const { el } = await mountDialog();
|
||||
await el.openEdit("e", {
|
||||
id: "t1", name: "Refill salt", type: "custom",
|
||||
schedule_type: "sensor_based", warning_days: 7, enabled: true,
|
||||
trigger_config: {
|
||||
type: "threshold", entity_id: "sensor.salt",
|
||||
trigger_below: 20, auto_complete_on_recovery: true,
|
||||
},
|
||||
} as any);
|
||||
await el.updateComplete;
|
||||
expect((el as any)._autoCompleteOnRecovery).to.be.true;
|
||||
});
|
||||
|
||||
it("sends the flag in trigger_config only when enabled", async () => {
|
||||
const { el, sent } = await mountDialog();
|
||||
await el.openCreate("e");
|
||||
(el as any)._name = "Refill salt";
|
||||
(el as any)._scheduleType = "sensor_based";
|
||||
(el as any)._triggerEntityId = "sensor.salt";
|
||||
(el as any)._triggerEntityIds = ["sensor.salt"];
|
||||
(el as any)._triggerType = "threshold";
|
||||
(el as any)._triggerBelow = "20";
|
||||
(el as any)._autoCompleteOnRecovery = true;
|
||||
await (el as any)._save();
|
||||
const msg = sent.find((m) => m.type === "maintenance_supporter/task/create") as any;
|
||||
expect(msg, "create message sent").to.exist;
|
||||
expect(msg.trigger_config.auto_complete_on_recovery).to.be.true;
|
||||
});
|
||||
|
||||
it("omits the flag when off (absence means off)", async () => {
|
||||
const { el, sent } = await mountDialog();
|
||||
await el.openCreate("e");
|
||||
(el as any)._name = "Refill salt";
|
||||
(el as any)._scheduleType = "sensor_based";
|
||||
(el as any)._triggerEntityId = "sensor.salt";
|
||||
(el as any)._triggerEntityIds = ["sensor.salt"];
|
||||
(el as any)._triggerType = "threshold";
|
||||
(el as any)._triggerBelow = "20";
|
||||
await (el as any)._save();
|
||||
const msg = sent.find((m) => m.type === "maintenance_supporter/task/create") as any;
|
||||
expect("auto_complete_on_recovery" in msg.trigger_config).to.be.false;
|
||||
});
|
||||
|
||||
it("renders the checkbox in the sensor trigger section", async () => {
|
||||
const { el } = await mountDialog();
|
||||
await el.openCreate("e");
|
||||
(el as any)._scheduleType = "sensor_based";
|
||||
(el as any)._triggerEntityId = "sensor.salt";
|
||||
(el as any)._triggerEntityIds = ["sensor.salt"];
|
||||
await el.updateComplete;
|
||||
const checkboxes = [...el.shadowRoot!.querySelectorAll('input[type="checkbox"]')];
|
||||
// delta-mode checkbox only shows for counter type; the recovery checkbox
|
||||
// shows for every sensor trigger type.
|
||||
expect(checkboxes.length).to.be.greaterThan(0);
|
||||
const label = [...el.shadowRoot!.querySelectorAll("label")].find((l) =>
|
||||
/auto-complete|recover/i.test(l.textContent || ""),
|
||||
);
|
||||
expect(label, "recovery checkbox label present").to.exist;
|
||||
});
|
||||
});
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
/** Lit component tests for <maintenance-task-documents>. */
|
||||
|
||||
import { expect, fixture, html } from "@open-wc/testing";
|
||||
import "../components/task-documents.js";
|
||||
import type { MaintenanceTaskDocuments } from "../components/task-documents";
|
||||
import { createMockHass } from "./_test-utils.js";
|
||||
|
||||
const LINKED = { id: "d1", kind: "file", title: "Manual", filename: "m.pdf", mime: "application/pdf", size: 100, tags: ["manual"], task_ids: ["t1"] };
|
||||
const AVAIL = { id: "d2", kind: "file", title: "Invoice", filename: "i.pdf", mime: "application/pdf", size: 50, tags: ["invoice"], task_ids: [] };
|
||||
|
||||
async function mount(canWrite = true, docs: unknown[] = [LINKED, 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"} .taskId=${"t1"} .canWrite=${canWrite}></maintenance-task-documents>
|
||||
`);
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
await el.updateComplete;
|
||||
return { el, sent };
|
||||
}
|
||||
|
||||
describe("task-documents", () => {
|
||||
it("lists only documents linked to the task", async () => {
|
||||
const { el } = await mount();
|
||||
const rows = el.shadowRoot!.querySelectorAll(".tdoc-row");
|
||||
expect(rows.length).to.equal(1);
|
||||
expect(rows[0].querySelector(".tdoc-title")!.textContent).to.contain("Manual");
|
||||
const opts = el.shadowRoot!.querySelectorAll(".tdoc-select option");
|
||||
expect([...opts].some((o) => o.textContent!.includes("Invoice")), "unlinked doc offered").to.be.true;
|
||||
});
|
||||
|
||||
it("links an available document to the task via WS", async () => {
|
||||
const { el, sent } = await mount();
|
||||
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!.task_ids).to.deep.equal(["t1"]);
|
||||
});
|
||||
|
||||
it("unlinks a linked document via WS", async () => {
|
||||
const { el, sent } = await mount();
|
||||
const unlink = [...el.shadowRoot!.querySelectorAll(".tdoc-row .icon-btn")].find(
|
||||
(b) => b.querySelector('ha-icon[icon="mdi:link-variant-off"]'),
|
||||
) as HTMLButtonElement;
|
||||
unlink.click();
|
||||
await el.updateComplete;
|
||||
const msg = sent.find((m) => m.type === "maintenance_supporter/documents/update" && m.doc_id === "d1");
|
||||
expect(msg, "unlink WS sent").to.exist;
|
||||
expect(msg!.task_ids).to.deep.equal([]);
|
||||
});
|
||||
|
||||
it("hides entirely when the object has no documents", async () => {
|
||||
const { el } = await mount(true, []);
|
||||
expect(el.shadowRoot!.querySelector(".task-docs"), "hidden with no docs").to.not.exist;
|
||||
});
|
||||
|
||||
it("sets a per-task jump-to page for a linked PDF via WS", async () => {
|
||||
const PDF = { id: "d1", kind: "file", title: "Manual", filename: "m.pdf", mime: "application/pdf", size: 100, tags: ["manual"], task_ids: ["t1"] };
|
||||
const { el, sent } = await mount(true, [PDF]);
|
||||
const input = el.shadowRoot!.querySelector<HTMLInputElement>(".tdoc-page");
|
||||
expect(input, "page field renders for a linked PDF").to.exist;
|
||||
input!.value = "7";
|
||||
input!.dispatchEvent(new Event("change"));
|
||||
await el.updateComplete;
|
||||
const msg = sent.find((m) => m.type === "maintenance_supporter/documents/update" && m.doc_id === "d1");
|
||||
expect(msg, "page update WS sent").to.exist;
|
||||
expect(msg!.task_pages).to.deep.equal({ t1: 7 });
|
||||
});
|
||||
|
||||
it("opens a paged PDF at its page via the #page fragment", async () => {
|
||||
const PDF = { id: "d1", kind: "file", title: "Manual", filename: "m.pdf", mime: "application/pdf", size: 100, tags: ["manual"], task_ids: ["t1"], task_pages: { t1: 12 } };
|
||||
const { hass } = createMockHass({
|
||||
handlers: {
|
||||
"maintenance_supporter/documents/list": () => ({ documents: [PDF] }),
|
||||
"auth/sign_path": () => ({ path: "/api/maintenance_supporter/document/d1?authSig=x" }),
|
||||
},
|
||||
});
|
||||
const el = await fixture<MaintenanceTaskDocuments>(html`
|
||||
<maintenance-task-documents .hass=${hass} .entryId=${"e1"} .taskId=${"t1"} .canWrite=${true}></maintenance-task-documents>
|
||||
`);
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
await el.updateComplete;
|
||||
|
||||
const win = { location: { href: "" }, close() {} };
|
||||
const orig = window.open;
|
||||
window.open = (() => win as unknown as Window) as typeof window.open;
|
||||
try {
|
||||
const eye = [...el.shadowRoot!.querySelectorAll(".tdoc-row .icon-btn")].find(
|
||||
(b) => b.querySelector('ha-icon[icon="mdi:eye-outline"]'),
|
||||
) as HTMLButtonElement;
|
||||
eye.click();
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(win.location.href).to.contain("#page=12");
|
||||
} finally {
|
||||
window.open = orig;
|
||||
}
|
||||
});
|
||||
|
||||
it("opens a document by clicking its title row (not just the eye icon)", async () => {
|
||||
const LINK = { id: "d3", kind: "weblink", title: "Online Manual", url: "https://x/m.pdf", tags: [], task_ids: ["t1"] };
|
||||
const orig = window.open;
|
||||
let openedUrl = "";
|
||||
window.open = ((u: string) => { openedUrl = u; return null; }) as typeof window.open;
|
||||
try {
|
||||
const { el } = await mount(true, [LINK]);
|
||||
const info = el.shadowRoot!.querySelector<HTMLElement>(".tdoc-row .tdoc-info")!;
|
||||
expect(info, "title row present").to.exist;
|
||||
expect(info.getAttribute("role"), "title row is a button").to.equal("button");
|
||||
info.click();
|
||||
await el.updateComplete;
|
||||
expect(openedUrl, "clicking the title opens the link").to.equal("https://x/m.pdf");
|
||||
} finally {
|
||||
window.open = orig;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
/** Tests for <maintenance-trigger-chart> + the shared chart utils. */
|
||||
|
||||
import { expect, fixture, html } from "@open-wc/testing";
|
||||
import "../components/trigger-chart.js";
|
||||
import type { MaintenanceTriggerChart } from "../components/trigger-chart";
|
||||
import { niceTicks, fmtNum } from "../renderers/chart-utils.js";
|
||||
|
||||
const DAY = 86400000;
|
||||
const NOW = Date.now();
|
||||
const POINTS = Array.from({ length: 10 }, (_, i) => ({
|
||||
ts: NOW - (9 - i) * DAY,
|
||||
val: 50 + i * 3, // 50 … 77
|
||||
}));
|
||||
|
||||
async function mount(props: Partial<MaintenanceTriggerChart> = {}) {
|
||||
const el = await fixture<MaintenanceTriggerChart>(html`
|
||||
<maintenance-trigger-chart
|
||||
.points=${props.points ?? POINTS}
|
||||
.events=${props.events ?? []}
|
||||
.unit=${"%"}
|
||||
.lang=${"en"}
|
||||
.thresholdBelow=${props.thresholdBelow ?? null}
|
||||
.thresholdAbove=${props.thresholdAbove ?? null}
|
||||
.targetValue=${props.targetValue ?? null}
|
||||
.forceZero=${props.forceZero ?? false}
|
||||
.rangeDays=${props.rangeDays ?? 30}
|
||||
></maintenance-trigger-chart>
|
||||
`);
|
||||
await new Promise((r) => setTimeout(r, 40)); // let the ResizeObserver fire
|
||||
await el.updateComplete;
|
||||
return el;
|
||||
}
|
||||
|
||||
describe("trigger-chart", () => {
|
||||
it("renders round y-ticks with gridlines at full width", async () => {
|
||||
const el = await mount();
|
||||
const svg = el.shadowRoot!.querySelector("svg")!;
|
||||
expect(svg, "svg rendered").to.exist;
|
||||
// width follows the host container, not a fixed 300
|
||||
expect(Number(svg.getAttribute("width"))).to.be.greaterThan(300);
|
||||
const labels = [...el.shadowRoot!.querySelectorAll("text.tick-label")].map((t) => t.textContent);
|
||||
// nice ticks over ~[48,79] land on round steps (50/60/70/80-ish)
|
||||
expect(labels.some((l) => /^(50|60|70|80)$/.test(l || "")), `round tick in ${labels}`).to.be.true;
|
||||
});
|
||||
|
||||
it("shades the danger zone and recolors in-zone line segments", async () => {
|
||||
const el = await mount({ thresholdBelow: 60 });
|
||||
const rects = el.shadowRoot!.querySelectorAll('rect[fill="var(--error-color, #f44336)"]');
|
||||
expect(rects.length, "zone shading rect").to.be.greaterThan(0);
|
||||
const redLine = el.shadowRoot!.querySelector('polyline[stroke="var(--error-color, #f44336)"]');
|
||||
expect(redLine, "in-zone line overlay (clip-path)").to.exist;
|
||||
const label = [...el.shadowRoot!.querySelectorAll("text.zone-label")].map((t) => t.textContent).join(" ");
|
||||
expect(label).to.contain("60");
|
||||
});
|
||||
|
||||
it("draws a target line for counter progress", async () => {
|
||||
const el = await mount({ targetValue: 100, forceZero: true });
|
||||
const label = [...el.shadowRoot!.querySelectorAll("text.zone-label")].map((t) => t.textContent).join(" ");
|
||||
expect(label).to.contain("100");
|
||||
// forceZero pulls the domain floor to 0
|
||||
const ticks = [...el.shadowRoot!.querySelectorAll("text.tick-label")].map((t) => t.textContent);
|
||||
expect(ticks).to.include("0");
|
||||
});
|
||||
|
||||
it("emits range-change from the range chips", async () => {
|
||||
const el = await mount({ rangeDays: 30 });
|
||||
let got = 0;
|
||||
el.addEventListener("range-change", (e) => { got = (e as CustomEvent).detail.days; });
|
||||
const chips = [...el.shadowRoot!.querySelectorAll<HTMLButtonElement>(".range-chip:not(.outlier-chip)")];
|
||||
expect(chips.length).to.equal(4);
|
||||
chips.find((c) => c.textContent!.trim() === "90d")!.click();
|
||||
expect(got).to.equal(90);
|
||||
});
|
||||
|
||||
it("emits outlier-toggle from the filter chip", async () => {
|
||||
const el = await mount({ rangeDays: 30 });
|
||||
let hide: boolean | null = null;
|
||||
el.addEventListener("outlier-toggle", (e) => { hide = (e as CustomEvent).detail.hide; });
|
||||
const chip = el.shadowRoot!.querySelector<HTMLButtonElement>(".outlier-chip");
|
||||
expect(chip, "outlier chip present").to.exist;
|
||||
chip!.click();
|
||||
expect(hide).to.equal(true);
|
||||
});
|
||||
|
||||
it("shows a crosshair value chip on pointer move", async () => {
|
||||
const el = await mount();
|
||||
const svg = el.shadowRoot!.querySelector("svg")!;
|
||||
const r = svg.getBoundingClientRect();
|
||||
svg.dispatchEvent(new PointerEvent("pointermove", { clientX: r.left + r.width / 2, clientY: r.top + 40, bubbles: true }));
|
||||
await el.updateComplete;
|
||||
expect(el.shadowRoot!.querySelector(".hover-chip"), "crosshair chip").to.exist;
|
||||
svg.dispatchEvent(new PointerEvent("pointerleave", { bubbles: true }));
|
||||
await el.updateComplete;
|
||||
expect(el.shadowRoot!.querySelector(".hover-chip"), "chip clears on leave").to.not.exist;
|
||||
});
|
||||
|
||||
it("renders completion markers in the bottom lane", async () => {
|
||||
const el = await mount({ events: [{ ts: NOW - 4 * DAY, type: "completed" }] });
|
||||
const marks = el.shadowRoot!.querySelectorAll('rect[fill="var(--success-color, #4caf50)"]');
|
||||
expect(marks.length).to.equal(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chart-utils", () => {
|
||||
it("niceTicks produces round inclusive bounds", () => {
|
||||
const { ticks, niceMin, niceMax } = niceTicks(53, 77, 4);
|
||||
expect(niceMin).to.be.at.most(53);
|
||||
expect(niceMax).to.be.at.least(77);
|
||||
for (const t of ticks) expect(t % 10 === 0 || t % 5 === 0, `tick ${t} round`).to.be.true;
|
||||
});
|
||||
|
||||
it("fmtNum is compact and consistent", () => {
|
||||
expect(fmtNum(88000)).to.equal("88k");
|
||||
expect(fmtNum(1500)).to.equal("1.5k");
|
||||
expect(fmtNum(-8007.1)).to.equal("-8k");
|
||||
expect(fmtNum(730)).to.equal("730");
|
||||
expect(fmtNum(7.53)).to.equal("7.5");
|
||||
expect(fmtNum(0)).to.equal("0");
|
||||
});
|
||||
});
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
/** Tests for the trigger-section renderer (progress header + stats-fallback note). */
|
||||
|
||||
import { expect } from "@open-wc/testing";
|
||||
import { render } from "lit";
|
||||
import { renderTriggerSection, type SparklineContext } from "../renderers/sparkline.js";
|
||||
import type { MaintenanceTask, StatisticsPoint } from "../types";
|
||||
|
||||
function ctx(overrides: Partial<SparklineContext> = {}): SparklineContext {
|
||||
return {
|
||||
lang: "en",
|
||||
detailStatsData: new Map<string, StatisticsPoint[]>(),
|
||||
hasStatsService: true,
|
||||
isCounterEntity: () => false,
|
||||
rangeDays: 30,
|
||||
setRangeDays: () => undefined,
|
||||
hideOutliers: false,
|
||||
setHideOutliers: () => undefined,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function task(overrides: Record<string, unknown>): MaintenanceTask {
|
||||
return {
|
||||
id: "t1",
|
||||
name: "Task",
|
||||
history: [],
|
||||
trigger_active: false,
|
||||
...overrides,
|
||||
} as unknown as MaintenanceTask;
|
||||
}
|
||||
|
||||
function mount(t: MaintenanceTask, c: SparklineContext): HTMLElement {
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
render(renderTriggerSection(t, c), host);
|
||||
return host;
|
||||
}
|
||||
|
||||
describe("trigger-section", () => {
|
||||
afterEach(() => {
|
||||
document.body.querySelectorAll("div").forEach((d) => d.remove());
|
||||
});
|
||||
|
||||
it("shows a progress header for state_change tasks (changes vs target)", () => {
|
||||
const host = mount(
|
||||
task({
|
||||
trigger_config: { type: "state_change", entity_id: "input_boolean.wm", trigger_target_changes: 8 },
|
||||
trigger_current_value: 5,
|
||||
}),
|
||||
ctx(),
|
||||
);
|
||||
const main = host.querySelector(".counter-progress-main");
|
||||
expect(main, "progress header rendered").to.exist;
|
||||
expect(main!.textContent).to.contain("5");
|
||||
expect(main!.textContent).to.contain("8");
|
||||
expect(host.querySelector(".counter-progress-pct")!.textContent).to.contain("63");
|
||||
});
|
||||
|
||||
it("shows a progress header for runtime tasks (hours vs target)", () => {
|
||||
const host = mount(
|
||||
task({
|
||||
trigger_config: { type: "runtime", entity_id: "input_boolean.comp", trigger_runtime_hours: 500 },
|
||||
trigger_current_value: 400,
|
||||
}),
|
||||
ctx(),
|
||||
);
|
||||
const pct = host.querySelector(".counter-progress-pct");
|
||||
expect(pct, "progress header rendered").to.exist;
|
||||
expect(pct!.textContent).to.contain("80");
|
||||
expect(pct!.classList.contains("near"), "80% renders in the warning tier").to.be.true;
|
||||
});
|
||||
|
||||
it("notes the statistics fallback when the entity has no long-term stats", () => {
|
||||
const history = [1, 2, 3].map((i) => ({
|
||||
timestamp: new Date(Date.now() - i * 86400000).toISOString(),
|
||||
type: "completed",
|
||||
trigger_value: i * 10,
|
||||
}));
|
||||
const host = mount(
|
||||
task({
|
||||
trigger_config: { type: "threshold", entity_id: "sensor.no_stats", trigger_below: 5 },
|
||||
trigger_current_value: 42,
|
||||
history,
|
||||
}),
|
||||
// Stats were fetched and came back EMPTY → fallback note expected.
|
||||
ctx({ detailStatsData: new Map([["sensor.no_stats", []]]) }),
|
||||
);
|
||||
expect(host.querySelector(".chart-note"), "fallback note shown").to.exist;
|
||||
});
|
||||
|
||||
it("shows no fallback note when real statistics exist", () => {
|
||||
const stats: StatisticsPoint[] = Array.from({ length: 5 }, (_, i) => ({
|
||||
ts: Date.now() - (5 - i) * 86400000,
|
||||
val: 50 + i,
|
||||
}));
|
||||
const host = mount(
|
||||
task({
|
||||
trigger_config: { type: "threshold", entity_id: "sensor.with_stats", trigger_below: 5 },
|
||||
trigger_current_value: 55,
|
||||
}),
|
||||
ctx({ detailStatsData: new Map([["sensor.with_stats", stats]]) }),
|
||||
);
|
||||
expect(host.querySelector(".chart-note"), "no note with real stats").to.not.exist;
|
||||
expect(host.querySelector("maintenance-trigger-chart"), "chart rendered").to.exist;
|
||||
});
|
||||
});
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Tests for <maintenance-vacation-section-card> (audit gap #10).
|
||||
*
|
||||
* An interactive, admin-gated Lovelace card that edits vacation mode from any
|
||||
* dashboard. Pins:
|
||||
* - non-admin users get a read-only card (no switch, no date inputs, no save)
|
||||
* - the enable toggle dispatches vacation/update {enabled}
|
||||
* - editing dates + Save dispatches vacation/update with start/end/buffer
|
||||
* - the status pill mirrors the server state (active / scheduled / inactive)
|
||||
*/
|
||||
|
||||
import { expect, fixture, html } from "@open-wc/testing";
|
||||
import "../components/vacation-section-card.js";
|
||||
import type { MaintenanceVacationSectionCard } from "../components/vacation-section-card";
|
||||
import { createMockHass } from "./_test-utils.js";
|
||||
|
||||
const BASE_STATE = {
|
||||
enabled: false,
|
||||
is_active: false,
|
||||
start: "2026-08-01",
|
||||
end: "2026-08-14",
|
||||
buffer_days: 7,
|
||||
exempt_task_ids: [],
|
||||
};
|
||||
|
||||
async function mount(opts: {
|
||||
isAdmin?: boolean;
|
||||
state?: Record<string, unknown>;
|
||||
} = {}) {
|
||||
const state = { ...BASE_STATE, ...(opts.state ?? {}) };
|
||||
const { hass, sent } = createMockHass({
|
||||
handlers: {
|
||||
"maintenance_supporter/vacation/state": () => state,
|
||||
"maintenance_supporter/vacation/update": (msg) => ({ ...state, ...msg }),
|
||||
"maintenance_supporter/vacation/end_now": () => ({ ...state, enabled: false, is_active: false }),
|
||||
},
|
||||
});
|
||||
(hass as Record<string, unknown>).user = { id: "u1", is_admin: opts.isAdmin ?? true };
|
||||
|
||||
const el = await fixture<MaintenanceVacationSectionCard>(html`
|
||||
<maintenance-vacation-section-card .hass=${hass}></maintenance-vacation-section-card>
|
||||
`);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
await el.updateComplete;
|
||||
return { el, sent };
|
||||
}
|
||||
|
||||
describe("vacation-section-card", () => {
|
||||
it("hides every edit control for non-admin users", async () => {
|
||||
const { el } = await mount({ isAdmin: false });
|
||||
const root = el.shadowRoot!;
|
||||
expect(root.querySelector("ha-card"), "card rendered").to.exist;
|
||||
expect(root.querySelector("ha-switch"), "no enable switch").to.be.null;
|
||||
expect(root.querySelector('input[type="date"]'), "no date inputs").to.be.null;
|
||||
expect(root.querySelector(".actions"), "no action buttons").to.be.null;
|
||||
});
|
||||
|
||||
it("admin toggle dispatches vacation/update {enabled: true}", async () => {
|
||||
const { el, sent } = await mount({ isAdmin: true });
|
||||
const sw = el.shadowRoot!.querySelector<HTMLInputElement>("ha-switch")!;
|
||||
expect(sw, "switch rendered for admin").to.exist;
|
||||
(sw as unknown as { checked: boolean }).checked = true;
|
||||
sw.dispatchEvent(new Event("change"));
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
const upd = sent.filter((m) => m.type === "maintenance_supporter/vacation/update");
|
||||
expect(upd.length).to.equal(1);
|
||||
expect(upd[0].enabled).to.equal(true);
|
||||
});
|
||||
|
||||
it("editing dates + Save dispatches start/end/buffer_days", async () => {
|
||||
const { el, sent } = await mount({ isAdmin: true });
|
||||
const root = el.shadowRoot!;
|
||||
const [start, end] = [...root.querySelectorAll<HTMLInputElement>('input[type="date"]')];
|
||||
start.value = "2026-09-01";
|
||||
start.dispatchEvent(new Event("input"));
|
||||
end.value = "2026-09-10";
|
||||
end.dispatchEvent(new Event("input"));
|
||||
const buffer = root.querySelector<HTMLInputElement>('input[type="number"]')!;
|
||||
buffer.value = "3";
|
||||
buffer.dispatchEvent(new Event("input"));
|
||||
await el.updateComplete;
|
||||
|
||||
// The dirty state arms the primary Save button.
|
||||
const save = [...root.querySelectorAll<HTMLButtonElement>(".actions .btn")]
|
||||
.find((b) => b.classList.contains("primary"))!;
|
||||
expect(save, "save button armed").to.exist;
|
||||
save.click();
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
const upd = sent.filter((m) => m.type === "maintenance_supporter/vacation/update");
|
||||
expect(upd.length).to.equal(1);
|
||||
expect(upd[0].start).to.equal("2026-09-01");
|
||||
expect(upd[0].end).to.equal("2026-09-10");
|
||||
expect(upd[0].buffer_days).to.equal(3);
|
||||
});
|
||||
|
||||
it("status pill mirrors the server state", async () => {
|
||||
const { el } = await mount({ state: { enabled: true, is_active: true } });
|
||||
const pill = el.shadowRoot!.querySelector(".status-pill")!;
|
||||
expect(pill.classList.contains("active")).to.be.true;
|
||||
|
||||
const { el: el2 } = await mount({ state: { enabled: true, is_active: false } });
|
||||
expect(el2.shadowRoot!.querySelector(".status-pill")!.classList.contains("scheduled"))
|
||||
.to.be.true;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
/** Pure-function tests for the task-table windowing math (helpers/virtual-window).
|
||||
*
|
||||
* Pins:
|
||||
* - the window covers the visible range plus overscan on both sides
|
||||
* - start/end snap to the step grid (re-render churn control)
|
||||
* - clamping at the top (scrollTop above the table) and at the bottom
|
||||
* - spacer heights always add up: padTop + rendered + padBottom == total
|
||||
* - degenerate inputs (0 rows, table far below viewport) stay sane
|
||||
*/
|
||||
|
||||
import { expect } from "@open-wc/testing";
|
||||
import { computeWindow, VIRTUAL_MIN_ROWS } from "../helpers/virtual-window.js";
|
||||
|
||||
const BASE = {
|
||||
viewportHeight: 500,
|
||||
listTop: 200,
|
||||
rowHeight: 50,
|
||||
total: 500,
|
||||
overscan: 12,
|
||||
step: 6,
|
||||
};
|
||||
|
||||
function invariant(w: { start: number; end: number; padTop: number; padBottom: number }) {
|
||||
expect(w.start).to.be.at.least(0);
|
||||
expect(w.end).to.be.at.most(BASE.total);
|
||||
expect(w.start).to.be.at.most(w.end);
|
||||
expect(w.padTop).to.equal(w.start * BASE.rowHeight);
|
||||
expect(w.padBottom).to.equal((BASE.total - w.end) * BASE.rowHeight);
|
||||
}
|
||||
|
||||
describe("virtual-window", () => {
|
||||
it("at the top the window starts at 0 with no top pad", () => {
|
||||
const w = computeWindow({ ...BASE, scrollTop: 0 });
|
||||
expect(w.start).to.equal(0);
|
||||
expect(w.padTop).to.equal(0);
|
||||
// 10 visible + 1 + 12 overscan, snapped up to a step multiple.
|
||||
expect(w.end).to.be.at.least(23);
|
||||
expect(w.end).to.be.at.most(30);
|
||||
invariant(w);
|
||||
});
|
||||
|
||||
it("mid-scroll covers the visible range plus overscan", () => {
|
||||
// scrollTop 1200 → firstVisible = (1200-200)/50 = row 20
|
||||
const w = computeWindow({ ...BASE, scrollTop: 1200 });
|
||||
expect(w.start).to.be.at.most(20 - BASE.overscan + BASE.step); // ≤ snapped(8)
|
||||
expect(w.start).to.be.at.least(0);
|
||||
expect(w.end).to.be.at.least(20 + 11); // last visible row rendered
|
||||
expect(w.start % BASE.step).to.equal(0);
|
||||
invariant(w);
|
||||
});
|
||||
|
||||
it("start/end snap to the step grid", () => {
|
||||
const a = computeWindow({ ...BASE, scrollTop: 1200 });
|
||||
const b = computeWindow({ ...BASE, scrollTop: 1200 + 49 }); // < 1 row later
|
||||
// A sub-row scroll may shift the window at most one step, never per-pixel.
|
||||
expect(Math.abs(b.start - a.start) % BASE.step).to.equal(0);
|
||||
});
|
||||
|
||||
it("clamps at the bottom: end == total and no bottom pad", () => {
|
||||
const w = computeWindow({ ...BASE, scrollTop: 200 + 500 * 50 }); // past the end
|
||||
expect(w.end).to.equal(BASE.total);
|
||||
expect(w.padBottom).to.equal(0);
|
||||
invariant(w);
|
||||
});
|
||||
|
||||
it("scrolled above the table (negative firstVisible) starts at 0", () => {
|
||||
const w = computeWindow({ ...BASE, scrollTop: 0, listTop: 5000 });
|
||||
expect(w.start).to.equal(0);
|
||||
expect(w.padTop).to.equal(0);
|
||||
expect(w.end).to.be.greaterThan(0);
|
||||
});
|
||||
|
||||
it("total 0 → empty window", () => {
|
||||
const w = computeWindow({ ...BASE, scrollTop: 0, total: 0 });
|
||||
expect(w).to.deep.equal({ start: 0, end: 0, padTop: 0, padBottom: 0 });
|
||||
});
|
||||
|
||||
it("pads + rendered slice always account for every row", () => {
|
||||
for (const scrollTop of [0, 137, 999, 5000, 12345, 26000]) {
|
||||
const w = computeWindow({ ...BASE, scrollTop });
|
||||
const rendered = w.end - w.start;
|
||||
expect(w.padTop + rendered * BASE.rowHeight + w.padBottom)
|
||||
.to.equal(BASE.total * BASE.rowHeight);
|
||||
}
|
||||
});
|
||||
|
||||
it("exports a sane virtualization threshold", () => {
|
||||
expect(VIRTUAL_MIN_ROWS).to.be.greaterThan(50);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Warranty status classification (#67) — pure + today-injectable.
|
||||
*
|
||||
* Pins the 4 states and the 60-day amber threshold so the object-detail chip
|
||||
* and the objects-table column stay in lockstep with the backend field.
|
||||
*/
|
||||
import { expect } from "@open-wc/testing";
|
||||
import { warrantyStatus, WARRANTY_WARN_DAYS } from "../helpers/warranty";
|
||||
|
||||
const TODAY = new Date(2026, 5, 1); // 2026-06-01 local
|
||||
|
||||
describe("warrantyStatus (#67)", () => {
|
||||
it("null / undefined / empty / invalid → none", () => {
|
||||
expect(warrantyStatus(null, TODAY).kind).to.equal("none");
|
||||
expect(warrantyStatus(undefined, TODAY).kind).to.equal("none");
|
||||
expect(warrantyStatus("", TODAY).kind).to.equal("none");
|
||||
expect(warrantyStatus("not-a-date", TODAY).kind).to.equal("none");
|
||||
});
|
||||
|
||||
it("far future → valid", () => {
|
||||
const s = warrantyStatus("2027-06-01", TODAY);
|
||||
expect(s.kind).to.equal("valid");
|
||||
expect(s.days).to.equal(365);
|
||||
});
|
||||
|
||||
it("61 days out (just past threshold) → valid", () => {
|
||||
const s = warrantyStatus("2026-08-01", TODAY); // Jun1 -> Aug1 = 61d
|
||||
expect(s.days).to.equal(61);
|
||||
expect(s.kind).to.equal("valid");
|
||||
});
|
||||
|
||||
it("exactly the warn threshold (60d) → expiring", () => {
|
||||
const s = warrantyStatus("2026-07-31", TODAY); // Jun1 -> Jul31 = 60d
|
||||
expect(s.days).to.equal(WARRANTY_WARN_DAYS);
|
||||
expect(s.kind).to.equal("expiring");
|
||||
});
|
||||
|
||||
it("within threshold → expiring", () => {
|
||||
const s = warrantyStatus("2026-06-30", TODAY); // 29d
|
||||
expect(s.kind).to.equal("expiring");
|
||||
expect(s.days).to.equal(29);
|
||||
});
|
||||
|
||||
it("today → expiring (0 days)", () => {
|
||||
const s = warrantyStatus("2026-06-01", TODAY);
|
||||
expect(s.kind).to.equal("expiring");
|
||||
expect(s.days).to.equal(0);
|
||||
});
|
||||
|
||||
it("past → expired (negative days)", () => {
|
||||
const s = warrantyStatus("2026-05-20", TODAY);
|
||||
expect(s.kind).to.equal("expired");
|
||||
expect(s.days).to.equal(-12);
|
||||
});
|
||||
|
||||
it("echoes the ISO date back (null when absent)", () => {
|
||||
expect(warrantyStatus("2027-06-01", TODAY).date).to.equal("2027-06-01");
|
||||
expect(warrantyStatus("2026-05-20", TODAY).date).to.equal("2026-05-20");
|
||||
expect(warrantyStatus(null, TODAY).date).to.equal(null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
/** Calendar tab / card styles — shared between panel-styles.ts and the
|
||||
* standalone maintenance-supporter-calendar-card.
|
||||
*
|
||||
* Extracted from panel-styles.ts in v1.9.0 so the new Lovelace card can
|
||||
* render identically to the panel's Calendar tab without dragging in the
|
||||
* full panel CSS.
|
||||
*/
|
||||
|
||||
import { css } from "lit";
|
||||
|
||||
export const calendarStyles = css`
|
||||
.cal-controls {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
.cal-window-chips {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
background: var(--card-background-color, var(--ha-card-background, #1c1c1c));
|
||||
border-radius: 999px;
|
||||
padding: 3px;
|
||||
}
|
||||
.cal-window-chip {
|
||||
padding: 6px 14px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--secondary-text-color);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border-radius: 999px;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
.cal-window-chip:hover { color: var(--primary-text-color); }
|
||||
.cal-window-chip.active {
|
||||
background: var(--primary-color);
|
||||
color: var(--text-primary-color, #fff);
|
||||
}
|
||||
/* v2.2.0 — past-window chips: visually distinguished from forward chips
|
||||
so the user grasps the time-direction switch at a glance. Uses a
|
||||
muted secondary tone instead of the primary blue. v2.3.x: explicit
|
||||
"−N d" / "+N d" prefixes + dot separator so past vs forward groups
|
||||
read at a glance instead of being two pill rows that look identical
|
||||
except for a small arrow. (User feedback: *"das −30 und die + sind
|
||||
noch schlecht angeordnet"*.) */
|
||||
.cal-past-chips {
|
||||
/* margin-right replaced by explicit separator below */
|
||||
}
|
||||
.cal-past-chip.active {
|
||||
background: var(--secondary-text-color, #888);
|
||||
}
|
||||
.cal-chip-separator {
|
||||
color: var(--divider-color);
|
||||
font-size: 8px;
|
||||
align-self: center;
|
||||
margin: 0 2px;
|
||||
line-height: 1;
|
||||
}
|
||||
.cal-user-filter {
|
||||
margin-left: auto;
|
||||
padding: 6px 10px;
|
||||
background: var(--card-background-color, var(--ha-card-background, #1c1c1c));
|
||||
color: var(--primary-text-color);
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.cal-rolling { padding: 8px 16px 32px; }
|
||||
.cal-day-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
.cal-day-pill {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 12px;
|
||||
background: var(--card-background-color, var(--ha-card-background, #1c1c1c));
|
||||
border: 1px solid var(--divider-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.cal-day-pill.cal-today {
|
||||
background: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
.cal-pill-weekday {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.cal-pill-day {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--primary-text-color);
|
||||
line-height: 1.1;
|
||||
}
|
||||
.cal-day-pill.cal-today .cal-pill-weekday,
|
||||
.cal-day-pill.cal-today .cal-pill-day {
|
||||
color: var(--text-primary-color, #fff);
|
||||
}
|
||||
.cal-day-content { flex: 1; min-width: 0; }
|
||||
.cal-day-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.cal-day-month { color: var(--secondary-text-color); font-size: 13px; }
|
||||
.cal-day-today-badge {
|
||||
color: var(--primary-color);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.cal-empty {
|
||||
color: var(--secondary-text-color);
|
||||
font-size: 13px;
|
||||
font-style: italic;
|
||||
padding: 4px 0 4px;
|
||||
}
|
||||
.cal-event {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 0;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.cal-event:hover { background: var(--state-icon-color, rgba(255,255,255,0.04)); }
|
||||
.cal-event-projected { opacity: 0.55; }
|
||||
.cal-event-body { flex: 1; min-width: 0; }
|
||||
.cal-event-title {
|
||||
font-size: 14px;
|
||||
color: var(--primary-text-color);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cal-event-recur {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--secondary-text-color);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.cal-event-icon {
|
||||
--mdc-icon-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.cal-source-time { color: var(--secondary-text-color); }
|
||||
.cal-source-sensor { color: var(--primary-color); }
|
||||
.cal-event-prediction {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
margin-top: 2px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--card-background-color, var(--ha-card-background, #1c1c1c));
|
||||
border: 1px solid var(--divider-color);
|
||||
}
|
||||
.cal-conf-high { color: var(--success-color, #4caf50); border-color: #4caf5044; }
|
||||
.cal-conf-medium { color: var(--warning-color, #f9a825); border-color: #f9a82544; }
|
||||
.cal-conf-low { color: var(--error-color, #d32f2f); border-color: #d32f2f44; }
|
||||
.cal-event-cost {
|
||||
font-size: 12px;
|
||||
color: var(--secondary-text-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.cal-status-pill {
|
||||
flex-shrink: 0;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
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; }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.cal-controls { padding: 10px 12px; }
|
||||
.cal-rolling { padding: 6px 12px 24px; }
|
||||
.cal-day-pill { width: 48px; height: 48px; }
|
||||
.cal-pill-day { font-size: 17px; }
|
||||
.cal-user-filter { margin-left: 0; width: 100%; }
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,547 @@
|
||||
/**
|
||||
* Capture README screenshots for the Maintenance Supporter integration.
|
||||
*
|
||||
* Prerequisites:
|
||||
* docker compose up -d # ha-maint
|
||||
* docker compose --profile testing up -d # playwright
|
||||
* python scripts/setup_demo.py && python scripts/seed_history.py
|
||||
* docker compose restart homeassistant-dev
|
||||
*
|
||||
* Usage:
|
||||
* cd custom_components/maintenance_supporter/frontend-src
|
||||
* node capture-readme-screenshots.mjs
|
||||
*
|
||||
* Or with explicit refresh token:
|
||||
* HA_REFRESH_TOKEN=<token> node capture-readme-screenshots.mjs
|
||||
*/
|
||||
|
||||
import { chromium } from "playwright";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const HA = "http://homeassistant-dev:8123";
|
||||
const OUTPUT = path.resolve(__dirname, "../../../docs/images");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Access token: env var or auto-extract from docker/.env
|
||||
// ---------------------------------------------------------------------------
|
||||
function getAccessToken() {
|
||||
if (process.env.HA_TOKEN) return process.env.HA_TOKEN;
|
||||
const envPath = path.resolve(__dirname, "../../../docker/.env");
|
||||
try {
|
||||
const lines = fs.readFileSync(envPath, "utf8").split("\n");
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("HA_TOKEN=")) return line.split("=")[1].trim();
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
console.error("Set HA_TOKEN or ensure docker/.env exists with HA_TOKEN=...");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const ACCESS_TOKEN = getAccessToken();
|
||||
fs.mkdirSync(OUTPUT, { recursive: true });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
function panelJS() {
|
||||
return `
|
||||
var ha = document.querySelector('home-assistant');
|
||||
var main = ha && ha.shadowRoot && ha.shadowRoot.querySelector('home-assistant-main');
|
||||
var drawer = main && main.shadowRoot && main.shadowRoot.querySelector('ha-drawer');
|
||||
var resolver = drawer && drawer.querySelector('partial-panel-resolver');
|
||||
var custom = resolver && resolver.querySelector('ha-panel-custom');
|
||||
var p = custom && custom.querySelector('maintenance-supporter-panel');
|
||||
`;
|
||||
}
|
||||
|
||||
async function login(page) {
|
||||
await page.goto(HA);
|
||||
await page.waitForTimeout(1000);
|
||||
await page.evaluate((token) => {
|
||||
localStorage.setItem("hassTokens", JSON.stringify({
|
||||
hassUrl: "http://homeassistant-dev:8123",
|
||||
clientId: "http://homeassistant-dev:8123/",
|
||||
refresh_token: "",
|
||||
access_token: token,
|
||||
token_type: "Bearer",
|
||||
expires_in: 86400,
|
||||
expires: Date.now() + 86400000,
|
||||
}));
|
||||
}, ACCESS_TOKEN);
|
||||
await page.goto(HA + "/maintenance-supporter");
|
||||
await page.waitForTimeout(8000);
|
||||
}
|
||||
|
||||
/** Returns true if page is still alive, false if it crashed. */
|
||||
async function setEnglishDark(page) {
|
||||
try {
|
||||
await page.evaluate(async () => {
|
||||
const ha = document.querySelector("home-assistant");
|
||||
const hass = ha && (ha.__hass || ha.hass);
|
||||
if (hass && hass.connection) {
|
||||
await hass.connection.sendMessagePromise({
|
||||
type: "frontend/set_user_data",
|
||||
key: "language",
|
||||
value: { language: "en", number_format: "language" },
|
||||
});
|
||||
await hass.connection.sendMessagePromise({
|
||||
type: "frontend/set_user_data",
|
||||
key: "core",
|
||||
value: { selectedTheme: { theme: "default", dark: true } },
|
||||
});
|
||||
}
|
||||
});
|
||||
await page.goto(HA + "/maintenance-supporter");
|
||||
await page.waitForTimeout(8000);
|
||||
return true;
|
||||
} catch {
|
||||
console.log(" (language/theme change crashed, will recover)");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function getObjectData(page, maxWait = 30000) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < maxWait) {
|
||||
const raw = await page.evaluate(`{
|
||||
${panelJS()}
|
||||
var result = [];
|
||||
if (p && p._objects) {
|
||||
for (var i = 0; i < p._objects.length; i++) {
|
||||
var obj = p._objects[i];
|
||||
var tasks = [];
|
||||
if (obj.tasks) for (var j = 0; j < obj.tasks.length; j++) {
|
||||
tasks.push({ id: obj.tasks[j].id, name: obj.tasks[j].name,
|
||||
checklist: obj.tasks[j].checklist || [],
|
||||
adaptive: !!(obj.tasks[j].adaptive_config && obj.tasks[j].adaptive_config.enabled) });
|
||||
}
|
||||
result.push({ entry_id: obj.entry_id, name: obj.object.name, tasks: tasks });
|
||||
}
|
||||
}
|
||||
JSON.stringify(result);
|
||||
}`);
|
||||
const data = JSON.parse(raw);
|
||||
if (data.length > 0) return data;
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
throw new Error("Timed out waiting for panel objects to load");
|
||||
}
|
||||
|
||||
function find(objects, objName, taskName) {
|
||||
const obj = objects.find((o) => o.name === objName);
|
||||
if (!obj) throw new Error(`Object '${objName}' not found`);
|
||||
if (!taskName) return { entryId: obj.entry_id, obj };
|
||||
const task = obj.tasks.find((t) => t.name === taskName);
|
||||
if (!task) throw new Error(`Task '${taskName}' not found in '${objName}'`);
|
||||
return { entryId: obj.entry_id, taskId: task.id, task, obj };
|
||||
}
|
||||
|
||||
async function shot(page, name) {
|
||||
const filePath = path.join(OUTPUT, name);
|
||||
await page.screenshot({ path: filePath });
|
||||
console.log(` ${name}`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
const browser = await chromium.connect("ws://localhost:3000");
|
||||
console.log("Connected to Playwright server\n");
|
||||
|
||||
// ======================== DESKTOP (1280×900) ========================
|
||||
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 }, locale: "en", colorScheme: "dark" });
|
||||
let page = await ctx.newPage();
|
||||
await login(page);
|
||||
const langOk = await setEnglishDark(page);
|
||||
if (!langOk) {
|
||||
// Language change crashed the page — recreate and re-login
|
||||
try { await page.close(); } catch { /* already dead */ }
|
||||
page = await ctx.newPage();
|
||||
await login(page);
|
||||
}
|
||||
|
||||
const objects = await getObjectData(page);
|
||||
console.log(`Found ${objects.length} objects: ${objects.map((o) => o.name).join(", ")}\n`);
|
||||
|
||||
// 1. Overview
|
||||
console.log("Desktop screenshots:");
|
||||
await shot(page, "overview.png");
|
||||
|
||||
// 2. Object detail — Electric Car (most tasks)
|
||||
const ev = find(objects, "Electric Car");
|
||||
await page.evaluate(`{ ${panelJS()} if (p) p._showObject('${ev.entryId}'); }`);
|
||||
await page.waitForTimeout(2000);
|
||||
await shot(page, "object-detail.png");
|
||||
|
||||
// 3. Task detail — HVAC Filter Replacement (threshold trigger with sparkline)
|
||||
const hvac = find(objects, "HVAC System", "Filter Replacement");
|
||||
await page.evaluate(`{ ${panelJS()} if (p) p._showTask('${hvac.entryId}', '${hvac.taskId}'); }`);
|
||||
await page.waitForTimeout(5000);
|
||||
await shot(page, "task-detail.png");
|
||||
|
||||
// 3b. Multi-entity trigger — Electric Car Tire Pressure Check (4 sensors, threshold < 2.0 bar)
|
||||
const tire = find(objects, "Electric Car", "Tire Pressure Check");
|
||||
await page.evaluate(`{ ${panelJS()} if (p) p._showTask('${tire.entryId}', '${tire.taskId}'); }`);
|
||||
await page.waitForTimeout(5000);
|
||||
await shot(page, "multi-entity-trigger.png");
|
||||
|
||||
// 3c. Compound trigger — Water Filter System (OR: threshold + counter)
|
||||
const wf = find(objects, "Water Filter System", "Cartridge Replacement");
|
||||
await page.evaluate(`{ ${panelJS()} if (p) p._showTask('${wf.entryId}', '${wf.taskId}'); }`);
|
||||
await page.waitForTimeout(5000);
|
||||
await shot(page, "compound-trigger.png");
|
||||
|
||||
// 4. Task history — Washing Machine Drum Cleaning (8 entries)
|
||||
const wm = find(objects, "Washing Machine", "Drum Cleaning");
|
||||
await page.evaluate(`{ ${panelJS()} if (p) p._showTask('${wm.entryId}', '${wm.taskId}'); }`);
|
||||
await page.waitForTimeout(2000);
|
||||
// Switch to history tab
|
||||
await page.evaluate(`{
|
||||
${panelJS()}
|
||||
if (p) {
|
||||
var tabs = p.shadowRoot && p.shadowRoot.querySelectorAll('.tab');
|
||||
if (tabs && tabs.length > 1) tabs[1].click();
|
||||
}
|
||||
}`);
|
||||
await page.waitForTimeout(1500);
|
||||
await shot(page, "task-history.png");
|
||||
|
||||
// 5. Complete dialog — use a task with checklist if possible
|
||||
const pool = find(objects, "Swimming Pool", "pH Test");
|
||||
await page.evaluate(`{ ${panelJS()} if (p) p._showTask('${pool.entryId}', '${pool.taskId}'); }`);
|
||||
await page.waitForTimeout(2000);
|
||||
await page.evaluate(`{
|
||||
${panelJS()}
|
||||
if (p) p._openCompleteDialog('${pool.entryId}', '${pool.taskId}', 'pH Test',
|
||||
${JSON.stringify(pool.task.checklist)}, ${pool.task.adaptive});
|
||||
}`);
|
||||
await page.waitForTimeout(1500);
|
||||
await shot(page, "complete-dialog.png");
|
||||
// Close dialog
|
||||
await page.keyboard.press("Escape");
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// 6. QR dialog
|
||||
await page.evaluate(`{
|
||||
${panelJS()}
|
||||
if (p) p._openQrForTask('${hvac.entryId}', '${hvac.taskId}', 'HVAC System', 'Filter Replacement');
|
||||
}`);
|
||||
await page.waitForTimeout(3000);
|
||||
await shot(page, "qr-dialog.png");
|
||||
await page.keyboard.press("Escape");
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// 7. Config flow — integration page
|
||||
await page.goto(HA + "/config/integrations/integration/maintenance_supporter");
|
||||
await page.waitForTimeout(4000);
|
||||
await shot(page, "config-flow.png");
|
||||
|
||||
// 8. Lovelace card — create temp dashboard via REST then screenshot
|
||||
try {
|
||||
// Create dashboard via lovelace WS — must return a promise
|
||||
const created = await page.evaluate(() => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ha = document.querySelector("home-assistant");
|
||||
const hass = ha && (ha.__hass || ha.hass);
|
||||
if (!hass || !hass.connection) { reject(new Error("no hass")); return; }
|
||||
hass.connection.sendMessagePromise({
|
||||
type: "lovelace/dashboards/create",
|
||||
url_path: "maintenance-demo",
|
||||
title: "Maintenance Demo",
|
||||
mode: "storage",
|
||||
}).then(() => resolve(true)).catch((e) => {
|
||||
// Dashboard may already exist, try saving config directly
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
// Save config to the dashboard
|
||||
await page.evaluate(() => {
|
||||
return new Promise((resolve) => {
|
||||
const ha = document.querySelector("home-assistant");
|
||||
const hass = ha && (ha.__hass || ha.hass);
|
||||
if (!hass) { resolve(); return; }
|
||||
hass.connection.sendMessagePromise({
|
||||
type: "lovelace/config/save",
|
||||
url_path: "maintenance-demo",
|
||||
config: {
|
||||
title: "Maintenance Demo",
|
||||
views: [{
|
||||
title: "Overview",
|
||||
cards: [{
|
||||
type: "custom:maintenance-supporter-card",
|
||||
title: "Maintenance Overview",
|
||||
show_header: true,
|
||||
show_actions: true,
|
||||
}],
|
||||
}],
|
||||
},
|
||||
}).then(() => resolve()).catch(() => resolve());
|
||||
});
|
||||
});
|
||||
await page.goto(HA + "/maintenance-demo/0");
|
||||
await page.waitForTimeout(6000);
|
||||
await shot(page, "lovelace-card.png");
|
||||
// Cleanup
|
||||
await page.evaluate(() => {
|
||||
const ha = document.querySelector("home-assistant");
|
||||
const hass = ha && (ha.__hass || ha.hass);
|
||||
if (hass) {
|
||||
hass.connection.sendMessagePromise({
|
||||
type: "lovelace/dashboards/delete",
|
||||
dashboard_id: "maintenance-demo",
|
||||
}).catch(() => {});
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(" lovelace-card.png SKIPPED:", e.message);
|
||||
}
|
||||
|
||||
// 9. Calendar — show month view with maintenance events
|
||||
await page.goto(HA + "/calendar");
|
||||
await page.waitForTimeout(5000);
|
||||
// Enable the maintenance calendar checkbox
|
||||
try {
|
||||
// Pre-select the maintenance calendar in localStorage so the panel shows it
|
||||
await page.evaluate(() => {
|
||||
// HA stores selected calendars in hass-panel-calendar-selected per-user
|
||||
// Try to find and click the checkbox through shadow DOM
|
||||
const ha = document.querySelector("home-assistant");
|
||||
const main = ha?.shadowRoot?.querySelector("home-assistant-main");
|
||||
const drawer = main?.shadowRoot?.querySelector("ha-drawer");
|
||||
const resolver = drawer?.querySelector("partial-panel-resolver");
|
||||
const calPanel = resolver?.querySelector("ha-panel-calendar");
|
||||
const sr = calPanel?.shadowRoot;
|
||||
if (!sr) return "no-shadow-root";
|
||||
// Try ha-check-list-item or ha-checkbox or mwc-checkbox
|
||||
const checkItems = sr.querySelectorAll("ha-check-list-item, ha-checkbox-list-item");
|
||||
for (const item of checkItems) {
|
||||
if (item.textContent?.includes("Maintenance")) {
|
||||
if (!item.selected && !item.checked) item.click();
|
||||
return "clicked";
|
||||
}
|
||||
}
|
||||
// Try ha-calendar-list-item approach
|
||||
const calList = sr.querySelector("ha-sidebar-calendars, .calendars");
|
||||
if (calList) {
|
||||
const labels = calList.querySelectorAll("label, li, div");
|
||||
for (const lbl of labels) {
|
||||
if (lbl.textContent?.includes("Maintenance")) {
|
||||
const cb = lbl.querySelector("input[type=checkbox], ha-checkbox");
|
||||
if (cb && !cb.checked) cb.click();
|
||||
return "clicked-inner";
|
||||
}
|
||||
}
|
||||
}
|
||||
// Dump what we find for debugging
|
||||
return "items:" + sr.innerHTML.substring(0, 500);
|
||||
}).then(r => console.log(" calendar checkbox:", r));
|
||||
await page.waitForTimeout(3000);
|
||||
} catch (e) { console.log(" calendar checkbox error:", e.message); }
|
||||
|
||||
// Advance the calendar by one month — with a freshly-seeded demo setup most
|
||||
// tasks' next_due dates are 30-90 days out, so the default "current month"
|
||||
// view typically renders empty. Attempt a pixel-click on the "Next" chevron
|
||||
// in HA's calendar toolbar (approx. (650, 85) at 1280×900); fall through
|
||||
// quietly if the click misses. If the resulting screenshot lands in an
|
||||
// empty month, prefer keeping the previously committed calendar.png over
|
||||
// overwriting it — `git checkout HEAD -- docs/images/calendar.png`.
|
||||
try {
|
||||
await page.mouse.click(650, 85);
|
||||
await page.waitForTimeout(2500);
|
||||
} catch (e) { console.log(" calendar next-month error:", e.message); }
|
||||
|
||||
await shot(page, "calendar.png");
|
||||
|
||||
// 10. Sensor attributes — Developer Tools States filtered to show one entity with all attributes
|
||||
await page.goto(HA + "/developer-tools/state");
|
||||
await page.waitForTimeout(4000);
|
||||
try {
|
||||
// Filter entities by typing into the search field
|
||||
// The filter input is deep in shadow DOM; use evaluate to find and focus it
|
||||
await page.evaluate(() => {
|
||||
const ha = document.querySelector("home-assistant");
|
||||
const main = ha && ha.shadowRoot && ha.shadowRoot.querySelector("home-assistant-main");
|
||||
const drawer = main && main.shadowRoot && main.shadowRoot.querySelector("ha-drawer");
|
||||
const resolver = drawer && drawer.querySelector("partial-panel-resolver");
|
||||
const devTools = resolver && resolver.querySelector("ha-panel-developer-tools");
|
||||
if (!devTools || !devTools.shadowRoot) return;
|
||||
const statesPanel = devTools.shadowRoot.querySelector("developer-tools-state");
|
||||
if (!statesPanel || !statesPanel.shadowRoot) return;
|
||||
// Find the entity filter input (search field in the table header)
|
||||
const searchInputs = statesPanel.shadowRoot.querySelectorAll("search-input, ha-search-input, input[type=search]");
|
||||
// Also try generic inputs
|
||||
const allInputs = statesPanel.shadowRoot.querySelectorAll("input");
|
||||
const target = searchInputs[0] || allInputs[0];
|
||||
if (target) {
|
||||
target.focus();
|
||||
target.value = "sensor.hvac_system";
|
||||
target.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
target.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
});
|
||||
await page.waitForTimeout(1000);
|
||||
// Fallback: use keyboard to type into whatever is focused
|
||||
await page.keyboard.type("sensor.hvac_system", { delay: 30 });
|
||||
await page.waitForTimeout(2000);
|
||||
} catch { /* filter may not work */ }
|
||||
await shot(page, "entity-attributes.png");
|
||||
|
||||
await ctx.close();
|
||||
|
||||
// ======================== MOBILE (375×812) ========================
|
||||
console.log("\nMobile screenshots:");
|
||||
try {
|
||||
const mCtx = await browser.newContext({ viewport: { width: 375, height: 812 }, locale: "en", isMobile: true, colorScheme: "dark" });
|
||||
const mPage = await mCtx.newPage();
|
||||
await login(mPage);
|
||||
// Skip setEnglishDark for mobile — sendMessage can crash mobile contexts
|
||||
|
||||
const mObjects = await getObjectData(mPage);
|
||||
|
||||
// 11. Mobile overview
|
||||
await shot(mPage, "mobile-overview.png");
|
||||
|
||||
// 12. Mobile task detail
|
||||
const mHvac = find(mObjects, "HVAC System", "Filter Replacement");
|
||||
await mPage.evaluate(`{ ${panelJS()} if (p) p._showTask('${mHvac.entryId}', '${mHvac.taskId}'); }`);
|
||||
await mPage.waitForTimeout(2000);
|
||||
await shot(mPage, "mobile-task.png");
|
||||
|
||||
await mCtx.close();
|
||||
} catch (e) {
|
||||
console.log(" mobile screenshots SKIPPED:", e.message);
|
||||
}
|
||||
|
||||
// ======================== NOTIFICATION MOCKUP (375×812) ========================
|
||||
// Rendered as pure HTML/CSS — no HA login needed, own context for stability
|
||||
console.log("\nNotification mockup:");
|
||||
const nCtx = await browser.newContext({ viewport: { width: 375, height: 812 }, locale: "en" });
|
||||
const notifPage = await nCtx.newPage();
|
||||
await notifPage.setContent(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=375, initial-scale=1">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
width: 375px; height: 812px;
|
||||
background: linear-gradient(135deg, #0a0a2e 0%, #1a1a3e 30%, #0d2137 70%, #0a1628 100%);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Helvetica Neue", sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
overflow: hidden; position: relative;
|
||||
}
|
||||
/* Lock screen clock */
|
||||
.lock-time {
|
||||
margin-top: 120px;
|
||||
font-size: 76px; font-weight: 700; letter-spacing: 1px;
|
||||
color: #fff;
|
||||
text-align: center; line-height: 1;
|
||||
}
|
||||
.lock-date {
|
||||
font-size: 20px; font-weight: 400; color: rgba(255,255,255,0.7);
|
||||
margin-top: 6px; text-align: center;
|
||||
}
|
||||
/* Notification card */
|
||||
.notif-card {
|
||||
margin-top: 60px; width: 345px;
|
||||
background: rgba(255,255,255,0.15);
|
||||
backdrop-filter: blur(40px); -webkit-backdrop-filter: blur(40px);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.notif-header {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 12px 14px 0 14px;
|
||||
}
|
||||
.notif-icon {
|
||||
width: 22px; height: 22px; border-radius: 5px;
|
||||
background: #038fc7;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.notif-icon svg { width: 14px; height: 14px; }
|
||||
.notif-app {
|
||||
font-size: 13px; font-weight: 500; color: rgba(255,255,255,0.6);
|
||||
flex: 1;
|
||||
}
|
||||
.notif-time {
|
||||
font-size: 13px; color: rgba(255,255,255,0.45);
|
||||
}
|
||||
.notif-body { padding: 6px 14px 14px 14px; }
|
||||
.notif-title {
|
||||
font-size: 15px; font-weight: 600; color: #fff;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.notif-message {
|
||||
font-size: 15px; font-weight: 400; color: rgba(255,255,255,0.85);
|
||||
line-height: 1.35;
|
||||
}
|
||||
/* Action buttons */
|
||||
.notif-actions {
|
||||
display: flex;
|
||||
border-top: 0.5px solid rgba(255,255,255,0.15);
|
||||
}
|
||||
.notif-action {
|
||||
flex: 1;
|
||||
padding: 13px 0;
|
||||
text-align: center;
|
||||
font-size: 15px; font-weight: 500;
|
||||
color: #64d2ff;
|
||||
cursor: pointer;
|
||||
}
|
||||
.notif-action:not(:last-child) {
|
||||
border-right: 0.5px solid rgba(255,255,255,0.15);
|
||||
}
|
||||
/* Home bar indicator */
|
||||
.home-bar {
|
||||
position: absolute; bottom: 8px;
|
||||
width: 134px; height: 5px;
|
||||
background: rgba(255,255,255,0.3);
|
||||
border-radius: 3px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="lock-time">9:41</div>
|
||||
<div class="lock-date">Saturday, March 7</div>
|
||||
|
||||
<div class="notif-card">
|
||||
<div class="notif-header">
|
||||
<div class="notif-icon">
|
||||
<svg viewBox="0 0 24 24" fill="white">
|
||||
<path d="M12 3L4 9v12h5v-7h6v7h5V9l-8-6z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="notif-app">HOME ASSISTANT</span>
|
||||
<span class="notif-time">now</span>
|
||||
</div>
|
||||
<div class="notif-body">
|
||||
<div class="notif-title">Maintenance Overdue!</div>
|
||||
<div class="notif-message">Oil Change for Family Car is 3 day(s) overdue!</div>
|
||||
</div>
|
||||
<div class="notif-actions">
|
||||
<div class="notif-action">\u2705 Complete</div>
|
||||
<div class="notif-action">\u23ed\ufe0f Skip</div>
|
||||
<div class="notif-action">\ud83d\udca4 Snooze</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="home-bar"></div>
|
||||
</body>
|
||||
</html>
|
||||
`, { waitUntil: "networkidle" });
|
||||
await notifPage.waitForTimeout(500);
|
||||
await shot(notifPage, "notification-actions.png");
|
||||
await notifPage.close();
|
||||
await nCtx.close();
|
||||
|
||||
await browser.close();
|
||||
|
||||
console.log(`\nAll screenshots saved to ${OUTPUT}`);
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* Capture screenshots of the user-visible features added between v1.0.22
|
||||
* and v1.0.41. Assumes:
|
||||
* - ha-maint is up with seeded demo data (setup_demo.py + seed_history.py)
|
||||
* - playwright-server is reachable on ws://localhost:3000
|
||||
* - docker/.env contains HA_TOKEN (or HA_REFRESH_TOKEN is in env)
|
||||
*
|
||||
* Writes PNGs into docs/images/ using filenames prefixed with the feature.
|
||||
*
|
||||
* Run:
|
||||
* cd custom_components/maintenance_supporter/frontend-src
|
||||
* node capture-v1041-screenshots.mjs
|
||||
*/
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
import { setup, ws, cleanup } from "./e2e-helpers.mjs";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const OUTPUT = path.resolve(__dirname, "../../../docs/images");
|
||||
fs.mkdirSync(OUTPUT, { recursive: true });
|
||||
|
||||
const PANEL_EVAL = `
|
||||
window._ha=document.querySelector('home-assistant');
|
||||
window._main=_ha.shadowRoot.querySelector('home-assistant-main');
|
||||
window._drawer=_main.shadowRoot.querySelector('ha-drawer');
|
||||
window._resolver=_drawer.querySelector('partial-panel-resolver');
|
||||
window._custom=_resolver.querySelector('ha-panel-custom');
|
||||
window._panel=_custom.querySelector('maintenance-supporter-panel');
|
||||
window._sr=_panel.shadowRoot;
|
||||
`;
|
||||
|
||||
async function screenshot(page, name, opts = {}) {
|
||||
const file = path.join(OUTPUT, `${name}.png`);
|
||||
await page.screenshot({ path: file, ...opts });
|
||||
console.log(` saved ${name}.png`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("== Launch browser + login ==");
|
||||
const { browser, ctx, page } = await setup({ mobile: false });
|
||||
|
||||
console.log("\n== Enable all advanced feature flags ==");
|
||||
await ws(page, {
|
||||
type: "maintenance_supporter/global/update",
|
||||
settings: {
|
||||
advanced_adaptive_visible: true,
|
||||
advanced_predictions_visible: true,
|
||||
advanced_seasonal_visible: true,
|
||||
advanced_environmental_visible: true,
|
||||
advanced_budget_visible: true,
|
||||
advanced_groups_visible: true,
|
||||
advanced_checklists_visible: true,
|
||||
advanced_schedule_time_visible: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log("\n== Pick a time-based task and enrich it ==");
|
||||
const objects = (await ws(page, { type: "maintenance_supporter/objects" })).objects;
|
||||
// Use a time_based task so the schedule_time field actually renders in the dialog
|
||||
const familyCar = objects.find(o => o.object.name === "Family Car");
|
||||
const tireTask = familyCar.tasks.find(t => t.name === "Tire Rotation");
|
||||
console.log(` target task: ${familyCar.object.name} / ${tireTask.name}`);
|
||||
|
||||
// Add a checklist + schedule_time so the screenshots have content.
|
||||
// Note: checklist is sent separately via task/update because schedule_time
|
||||
// is currently accepted by the WS schema for any schedule_type.
|
||||
await ws(page, {
|
||||
type: "maintenance_supporter/task/update",
|
||||
entry_id: familyCar.entry_id,
|
||||
task_id: tireTask.id,
|
||||
checklist: [
|
||||
"Raise the vehicle with a jack at each wheel",
|
||||
"Loosen and remove each wheel's lug nuts",
|
||||
"Rotate tires: front-to-back, same side (FWD) or crosswise (AWD/RWD)",
|
||||
"Torque lug nuts to manufacturer spec (~110 Nm)",
|
||||
"Reset TPMS learn mode if equipped",
|
||||
],
|
||||
schedule_time: "09:00",
|
||||
});
|
||||
|
||||
// Also give the HVAC filter a checklist so the sensor_based detail view is still
|
||||
// useful elsewhere — but the primary demo task is now Tire Rotation.
|
||||
const hvac = objects.find(o => o.object.name === "HVAC System");
|
||||
const filterTask = hvac.tasks.find(t => t.name.toLowerCase().includes("filter"));
|
||||
|
||||
console.log("\n== Give the HVAC filter task manual seasonal overrides ==");
|
||||
try {
|
||||
await ws(page, {
|
||||
type: "maintenance_supporter/task/seasonal_overrides",
|
||||
entry_id: hvac.entry_id,
|
||||
task_id: filterTask.id,
|
||||
overrides: {1: 1.4, 2: 1.3, 7: 0.6, 8: 0.6, 12: 1.5},
|
||||
});
|
||||
} catch (e) { console.log(" seasonal skipped:", e?.message); }
|
||||
|
||||
console.log("\n== Create a demo group so the groups section renders ==");
|
||||
try {
|
||||
await ws(page, {
|
||||
type: "maintenance_supporter/group/create",
|
||||
name: "Quarterly Appliances",
|
||||
description: "All tasks for appliances on a 90-day cycle",
|
||||
task_refs: [
|
||||
{ entry_id: familyCar.entry_id, task_id: tireTask.id },
|
||||
{ entry_id: hvac.entry_id, task_id: filterTask.id },
|
||||
],
|
||||
});
|
||||
} catch (e) { console.log(" group skipped:", e?.message); }
|
||||
|
||||
// Force-refresh the panel so the new state shows up
|
||||
await page.evaluate((fn) => { eval(fn); return window._panel._loadData(); }, PANEL_EVAL);
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
console.log("\n== (1) panel overview with KPIs, budget bar, status chips ==");
|
||||
await page.evaluate((fn) => {
|
||||
eval(fn);
|
||||
window._panel._view = "overview";
|
||||
window._panel.requestUpdate();
|
||||
}, PANEL_EVAL);
|
||||
await page.waitForTimeout(1500);
|
||||
await screenshot(page, "overview-v1041");
|
||||
|
||||
console.log("\n== (2) settings view with 8 feature toggles ==");
|
||||
await page.evaluate((fn) => {
|
||||
eval(fn);
|
||||
window._panel._view = "overview";
|
||||
window._panel._overviewTab = "settings";
|
||||
window._panel.requestUpdate();
|
||||
}, PANEL_EVAL);
|
||||
await page.waitForTimeout(1500);
|
||||
// Scroll down so the 8 feature flags are visible
|
||||
await page.evaluate((fn) => {
|
||||
eval(fn);
|
||||
const sv = window._sr.querySelector("maintenance-settings-view");
|
||||
if (sv?.shadowRoot) {
|
||||
const el = sv.shadowRoot.querySelector(".features-section, .features, .feature-flags");
|
||||
if (el?.scrollIntoView) el.scrollIntoView({ block: "start" });
|
||||
}
|
||||
}, PANEL_EVAL);
|
||||
await page.waitForTimeout(500);
|
||||
await screenshot(page, "settings-features", { fullPage: true });
|
||||
|
||||
console.log("\n== (3) task detail with checklist preview + schedule_time ==");
|
||||
await page.evaluate(({ eid, tid, fn }) => {
|
||||
eval(fn);
|
||||
window._panel._selectedEntryId = eid;
|
||||
window._panel._selectedTaskId = tid;
|
||||
window._panel._view = "task";
|
||||
window._panel.requestUpdate();
|
||||
}, { eid: familyCar.entry_id, tid: tireTask.id, fn: PANEL_EVAL });
|
||||
await page.waitForTimeout(1500);
|
||||
await screenshot(page, "task-detail-checklist-and-time", { fullPage: true });
|
||||
|
||||
console.log("\n== (4) task edit dialog with schedule_time + checklist editor ==");
|
||||
const freshTask = (await ws(page, { type: "maintenance_supporter/object", entry_id: familyCar.entry_id }))
|
||||
.tasks.find(t => t.id === tireTask.id);
|
||||
await page.evaluate(({ eid, task, fn }) => {
|
||||
eval(fn);
|
||||
window._sr.querySelector("maintenance-task-dialog").openEdit(eid, task);
|
||||
}, { eid: familyCar.entry_id, task: freshTask, fn: PANEL_EVAL });
|
||||
await page.waitForTimeout(1200);
|
||||
await screenshot(page, "task-dialog-new-fields");
|
||||
|
||||
// Scroll the dialog to the checklist textarea so it's visible in the shot
|
||||
await page.evaluate((fn) => {
|
||||
eval(fn);
|
||||
const content = window._sr.querySelector("maintenance-task-dialog").shadowRoot.querySelector(".content");
|
||||
const ta = window._sr.querySelector("maintenance-task-dialog").shadowRoot.querySelector("textarea.checklist-textarea");
|
||||
if (ta && content) content.scrollTop = Math.max(0, ta.offsetTop - 100);
|
||||
}, PANEL_EVAL);
|
||||
await page.waitForTimeout(500);
|
||||
await screenshot(page, "task-dialog-checklist-editor");
|
||||
|
||||
// Close
|
||||
await page.evaluate((fn) => {
|
||||
eval(fn);
|
||||
window._sr.querySelector("maintenance-task-dialog")._open = false;
|
||||
}, PANEL_EVAL);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
console.log("\n== (5) object dialog with ha-area-picker ==");
|
||||
await page.evaluate(({ eid, fn }) => {
|
||||
eval(fn);
|
||||
// object-dialog doesn't have an openEdit that takes an id — find the
|
||||
// "Edit object" entrypoint via the panel's menu
|
||||
const obj = window._panel._objects.find(o => o.entry_id === eid);
|
||||
if (obj) {
|
||||
window._sr.querySelector("maintenance-object-dialog").openEdit(obj.entry_id, obj.object);
|
||||
}
|
||||
}, { eid: hvac.entry_id, fn: PANEL_EVAL });
|
||||
await page.waitForTimeout(1200);
|
||||
await screenshot(page, "object-dialog-area-picker");
|
||||
await page.evaluate((fn) => {
|
||||
eval(fn);
|
||||
window._sr.querySelector("maintenance-object-dialog")._open = false;
|
||||
}, PANEL_EVAL);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
console.log("\n== (6) seasonal overrides dialog (12-month editor) ==");
|
||||
try {
|
||||
const seasonalTask = (await ws(page, { type: "maintenance_supporter/object", entry_id: hvac.entry_id }))
|
||||
.tasks.find(t => t.id === filterTask.id);
|
||||
await page.evaluate(({ eid, tid, fn }) => {
|
||||
eval(fn);
|
||||
window._panel._selectedEntryId = eid;
|
||||
window._panel._selectedTaskId = tid;
|
||||
window._panel._view = "task";
|
||||
window._panel.requestUpdate();
|
||||
}, { eid: hvac.entry_id, tid: filterTask.id, fn: PANEL_EVAL });
|
||||
await page.waitForTimeout(1000);
|
||||
await page.evaluate(({ task, fn }) => {
|
||||
eval(fn);
|
||||
window._sr.querySelector("maintenance-seasonal-overrides-dialog")
|
||||
.open(window._panel._selectedEntryId, window._panel._selectedTaskId, task.adaptive_config?.seasonal_overrides);
|
||||
}, { task: seasonalTask, fn: PANEL_EVAL });
|
||||
await page.waitForTimeout(1200);
|
||||
await screenshot(page, "seasonal-overrides-dialog");
|
||||
await page.evaluate((fn) => {
|
||||
eval(fn);
|
||||
window._sr.querySelector("maintenance-seasonal-overrides-dialog")._open = false;
|
||||
}, PANEL_EVAL);
|
||||
} catch (e) { console.log(" seasonal-dialog skipped:", e?.message); }
|
||||
|
||||
console.log("\n== (7) localized validation error in dialog ==");
|
||||
// Open a task dialog, paste an oversized name, hit save
|
||||
await page.evaluate(({ eid, task, fn }) => {
|
||||
eval(fn);
|
||||
window._sr.querySelector("maintenance-task-dialog").openEdit(eid, task);
|
||||
}, { eid: familyCar.entry_id, task: freshTask, fn: PANEL_EVAL });
|
||||
await page.waitForTimeout(1000);
|
||||
await page.evaluate((fn) => {
|
||||
eval(fn);
|
||||
const dlg = window._sr.querySelector("maintenance-task-dialog");
|
||||
dlg._name = "X".repeat(500); // triggers WS vol.Length rejection
|
||||
dlg.requestUpdate();
|
||||
}, PANEL_EVAL);
|
||||
await page.waitForTimeout(300);
|
||||
await page.evaluate((fn) => {
|
||||
eval(fn);
|
||||
window._sr.querySelector("maintenance-task-dialog")._save();
|
||||
}, PANEL_EVAL);
|
||||
await page.waitForTimeout(1200);
|
||||
await screenshot(page, "validation-error-localized");
|
||||
|
||||
await cleanup(browser, ctx);
|
||||
console.log("\n== Done. Screenshots in docs/images/ ==");
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* Capture v1.3.1 screenshots: completion-actions feature flag toggle, the
|
||||
* task-dialog On-Complete Action section with the new ha-service-picker,
|
||||
* and the schema-driven ha-form for service data.
|
||||
*
|
||||
* Reuses the same login/panel-traversal pattern as
|
||||
* capture-readme-screenshots.mjs.
|
||||
*
|
||||
* docker compose --profile testing up -d # ensure playwright is up
|
||||
* node capture-v131-screenshots.mjs
|
||||
*/
|
||||
|
||||
import { chromium } from "playwright";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const HA = "http://homeassistant-dev:8123";
|
||||
const OUTPUT = path.resolve(__dirname, "../../../docs/images");
|
||||
fs.mkdirSync(OUTPUT, { recursive: true });
|
||||
|
||||
function token() {
|
||||
if (process.env.HA_TOKEN) return process.env.HA_TOKEN;
|
||||
const env = path.resolve(__dirname, "../../../docker/.env");
|
||||
const lines = fs.readFileSync(env, "utf8").split("\n");
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("HA_TOKEN=")) return line.split("=")[1].trim();
|
||||
}
|
||||
throw new Error("HA_TOKEN not found");
|
||||
}
|
||||
|
||||
const ACCESS_TOKEN = token();
|
||||
|
||||
function panelJS() {
|
||||
return `
|
||||
var ha = document.querySelector('home-assistant');
|
||||
var main = ha && ha.shadowRoot && ha.shadowRoot.querySelector('home-assistant-main');
|
||||
var drawer = main && main.shadowRoot && main.shadowRoot.querySelector('ha-drawer');
|
||||
var resolver = drawer && drawer.querySelector('partial-panel-resolver');
|
||||
var custom = resolver && resolver.querySelector('ha-panel-custom');
|
||||
var p = custom && custom.querySelector('maintenance-supporter-panel');
|
||||
`;
|
||||
}
|
||||
|
||||
async function login(page) {
|
||||
await page.goto(HA);
|
||||
await page.waitForTimeout(1000);
|
||||
await page.evaluate((t) => {
|
||||
localStorage.setItem("hassTokens", JSON.stringify({
|
||||
hassUrl: "http://homeassistant-dev:8123",
|
||||
clientId: "http://homeassistant-dev:8123/",
|
||||
refresh_token: "",
|
||||
access_token: t,
|
||||
token_type: "Bearer",
|
||||
expires_in: 86400,
|
||||
expires: Date.now() + 86400000,
|
||||
}));
|
||||
}, ACCESS_TOKEN);
|
||||
await page.goto(HA + "/maintenance-supporter");
|
||||
await page.waitForTimeout(8000);
|
||||
}
|
||||
|
||||
async function setEnglishDark(page) {
|
||||
await page.evaluate(async () => {
|
||||
const ha = document.querySelector("home-assistant");
|
||||
const hass = ha && (ha.__hass || ha.hass);
|
||||
if (hass && hass.connection) {
|
||||
await hass.connection.sendMessagePromise({
|
||||
type: "frontend/set_user_data", key: "language",
|
||||
value: { language: "en", number_format: "language" },
|
||||
});
|
||||
await hass.connection.sendMessagePromise({
|
||||
type: "frontend/set_user_data", key: "core",
|
||||
value: { selectedTheme: { theme: "default", dark: true } },
|
||||
});
|
||||
}
|
||||
});
|
||||
await page.goto(HA + "/maintenance-supporter");
|
||||
await page.waitForTimeout(8000);
|
||||
}
|
||||
|
||||
async function shot(page, name) {
|
||||
const filePath = path.join(OUTPUT, name);
|
||||
await page.screenshot({ path: filePath });
|
||||
console.log(` ${name}`);
|
||||
}
|
||||
|
||||
async function enableCompletionActionsFeature(page) {
|
||||
// Flip the feature flag via the WS settings update.
|
||||
const result = await page.evaluate(async () => {
|
||||
const ha = document.querySelector("home-assistant");
|
||||
const hass = ha && (ha.__hass || ha.hass);
|
||||
try {
|
||||
const r = await hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/global/update",
|
||||
settings: { advanced_completion_actions_visible: true },
|
||||
});
|
||||
return { ok: true, result: r };
|
||||
} catch (e) {
|
||||
return { ok: false, error: JSON.stringify(e) };
|
||||
}
|
||||
});
|
||||
console.log(" flag result:", JSON.stringify(result));
|
||||
// Reload to pick up the flag.
|
||||
await page.goto(HA + "/maintenance-supporter");
|
||||
await page.waitForTimeout(6000);
|
||||
}
|
||||
|
||||
async function getFirstTask(page) {
|
||||
const raw = await page.evaluate(`{
|
||||
${panelJS()}
|
||||
var result = null;
|
||||
if (p && p._objects && p._objects.length > 0) {
|
||||
for (var i = 0; i < p._objects.length; i++) {
|
||||
var obj = p._objects[i];
|
||||
if (obj.tasks && obj.tasks.length > 0) {
|
||||
result = { entry_id: obj.entry_id, task_id: obj.tasks[0].id, name: obj.tasks[0].name, obj_name: obj.object.name };
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
JSON.stringify(result);
|
||||
}`);
|
||||
const r = JSON.parse(raw);
|
||||
if (!r) throw new Error("No tasks found in demo data");
|
||||
return r;
|
||||
}
|
||||
|
||||
async function openEditDialog(page, entryId, taskId) {
|
||||
// Navigate to task detail first so the dialog has full context.
|
||||
await page.evaluate(`{
|
||||
${panelJS()}
|
||||
if (p) p._showTask('${entryId}', '${taskId}');
|
||||
}`);
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
// Open the edit dialog programmatically via the panel's task-dialog ref.
|
||||
await page.evaluate(`{
|
||||
${panelJS()}
|
||||
if (p) {
|
||||
var dlg = p.shadowRoot && p.shadowRoot.querySelector('maintenance-task-dialog');
|
||||
if (dlg) {
|
||||
// Find the task object from p._objects
|
||||
var task = null;
|
||||
for (var i = 0; i < p._objects.length; i++) {
|
||||
if (p._objects[i].entry_id === '${entryId}') {
|
||||
for (var j = 0; j < p._objects[i].tasks.length; j++) {
|
||||
if (p._objects[i].tasks[j].id === '${taskId}') {
|
||||
task = p._objects[i].tasks[j];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (task) dlg.openEdit('${entryId}', task);
|
||||
}
|
||||
}
|
||||
}`);
|
||||
await page.waitForTimeout(3000);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
const browser = await chromium.connect("ws://localhost:3000");
|
||||
console.log("Connected to Playwright server\n");
|
||||
|
||||
const ctx = await browser.newContext({
|
||||
viewport: { width: 1280, height: 900 },
|
||||
locale: "en",
|
||||
colorScheme: "dark",
|
||||
});
|
||||
const page = await ctx.newPage();
|
||||
|
||||
console.log("Login + setup …");
|
||||
await login(page);
|
||||
await setEnglishDark(page);
|
||||
|
||||
console.log("Enable completion-actions feature flag …");
|
||||
await enableCompletionActionsFeature(page);
|
||||
|
||||
// ─── Settings tab — feature toggle ─────────────────────────────────────
|
||||
console.log("\nSettings tab screenshots:");
|
||||
await page.evaluate(`{
|
||||
${panelJS()}
|
||||
if (p) p._activeView = 'settings';
|
||||
if (p) p.requestUpdate();
|
||||
}`);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Try to scroll to the Features section
|
||||
await page.evaluate(`{
|
||||
${panelJS()}
|
||||
if (p) {
|
||||
var sv = p.shadowRoot && p.shadowRoot.querySelector('maintenance-settings-view');
|
||||
if (sv && sv.shadowRoot) {
|
||||
var features = sv.shadowRoot.querySelector('.features-section, [data-section=features]');
|
||||
if (features) features.scrollIntoView({behavior: 'instant', block: 'start'});
|
||||
}
|
||||
}
|
||||
}`);
|
||||
await page.waitForTimeout(1500);
|
||||
await shot(page, "settings-completion-actions-toggle.png");
|
||||
|
||||
// ─── Open edit dialog with on_complete_action visible ─────────────────
|
||||
console.log("\nTask-dialog screenshots:");
|
||||
const t = await getFirstTask(page);
|
||||
console.log(` Using task: ${t.obj_name} → ${t.name}`);
|
||||
await openEditDialog(page, t.entry_id, t.task_id);
|
||||
|
||||
// Programmatically open the on_complete_action <details>
|
||||
await page.evaluate(`{
|
||||
${panelJS()}
|
||||
if (p) {
|
||||
var dlg = p.shadowRoot && p.shadowRoot.querySelector('maintenance-task-dialog');
|
||||
if (dlg && dlg.shadowRoot) {
|
||||
var detailsEls = dlg.shadowRoot.querySelectorAll('details.ca-section');
|
||||
detailsEls.forEach(function(d) { d.open = true; });
|
||||
// Scroll the first details into view
|
||||
if (detailsEls[0]) detailsEls[0].scrollIntoView({behavior: 'instant', block: 'center'});
|
||||
}
|
||||
}
|
||||
}`);
|
||||
await page.waitForTimeout(1500);
|
||||
await shot(page, "task-dialog-action-section-empty.png");
|
||||
|
||||
// Set service to light.turn_on so ha-form renders
|
||||
console.log(" Setting service = light.turn_on …");
|
||||
await page.evaluate(`{
|
||||
${panelJS()}
|
||||
if (p) {
|
||||
var dlg = p.shadowRoot && p.shadowRoot.querySelector('maintenance-task-dialog');
|
||||
if (dlg) {
|
||||
dlg._actionService = 'light.turn_on';
|
||||
dlg.requestUpdate();
|
||||
}
|
||||
}
|
||||
}`);
|
||||
await page.waitForTimeout(2000);
|
||||
await page.evaluate(`{
|
||||
${panelJS()}
|
||||
if (p) {
|
||||
var dlg = p.shadowRoot && p.shadowRoot.querySelector('maintenance-task-dialog');
|
||||
if (dlg && dlg.shadowRoot) {
|
||||
var d = dlg.shadowRoot.querySelector('details.ca-section');
|
||||
if (d) { d.open = true; d.scrollIntoView({behavior: 'instant', block: 'center'}); }
|
||||
}
|
||||
}
|
||||
}`);
|
||||
await page.waitForTimeout(1000);
|
||||
await shot(page, "task-dialog-action-form-light.png");
|
||||
|
||||
// Pick a service with no schema → JSON fallback
|
||||
console.log(" Setting service = button.press (no schema) …");
|
||||
await page.evaluate(`{
|
||||
${panelJS()}
|
||||
if (p) {
|
||||
var dlg = p.shadowRoot && p.shadowRoot.querySelector('maintenance-task-dialog');
|
||||
if (dlg) {
|
||||
dlg._actionService = 'button.press';
|
||||
dlg.requestUpdate();
|
||||
}
|
||||
}
|
||||
}`);
|
||||
await page.waitForTimeout(2000);
|
||||
await page.evaluate(`{
|
||||
${panelJS()}
|
||||
if (p) {
|
||||
var dlg = p.shadowRoot && p.shadowRoot.querySelector('maintenance-task-dialog');
|
||||
if (dlg && dlg.shadowRoot) {
|
||||
var d = dlg.shadowRoot.querySelector('details.ca-section');
|
||||
if (d) { d.open = true; d.scrollIntoView({behavior: 'instant', block: 'center'}); }
|
||||
}
|
||||
}
|
||||
}`);
|
||||
await page.waitForTimeout(1000);
|
||||
await shot(page, "task-dialog-action-form-fallback.png");
|
||||
|
||||
// Quick-complete defaults section
|
||||
console.log(" Quick-complete defaults section …");
|
||||
await page.evaluate(`{
|
||||
${panelJS()}
|
||||
if (p) {
|
||||
var dlg = p.shadowRoot && p.shadowRoot.querySelector('maintenance-task-dialog');
|
||||
if (dlg && dlg.shadowRoot) {
|
||||
var detailsEls = dlg.shadowRoot.querySelectorAll('details.ca-section');
|
||||
if (detailsEls[1]) {
|
||||
detailsEls[1].open = true;
|
||||
detailsEls[1].scrollIntoView({behavior: 'instant', block: 'center'});
|
||||
}
|
||||
}
|
||||
}
|
||||
}`);
|
||||
await page.waitForTimeout(1500);
|
||||
await shot(page, "task-dialog-quick-complete-defaults.png");
|
||||
|
||||
console.log("\nDone.");
|
||||
await browser.close();
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Capture v1.4.x screenshots:
|
||||
* - object-detail-with-manual.png #43: documentation_url on object header
|
||||
* - task-detail-with-manual.png #43+1.4.1: parent manual link on task page
|
||||
* - settings-notification-title-style.png #44: dropdown
|
||||
* - overview-v142.png refreshed dashboard with v1.4 polish
|
||||
*
|
||||
* Same login + panel-traversal pattern as capture-v131-screenshots.mjs.
|
||||
*
|
||||
* docker compose --profile testing up -d # ensure playwright is up
|
||||
* node capture-v14-screenshots.mjs
|
||||
*/
|
||||
|
||||
import { chromium } from "playwright";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const HA = "http://homeassistant-dev:8123";
|
||||
const OUTPUT = path.resolve(__dirname, "../../../docs/images");
|
||||
fs.mkdirSync(OUTPUT, { recursive: true });
|
||||
|
||||
function token() {
|
||||
if (process.env.HA_TOKEN) return process.env.HA_TOKEN;
|
||||
const env = path.resolve(__dirname, "../../../docker/.env");
|
||||
const lines = fs.readFileSync(env, "utf8").split("\n");
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("HA_TOKEN=")) return line.split("=")[1].trim();
|
||||
}
|
||||
throw new Error("HA_TOKEN not found");
|
||||
}
|
||||
|
||||
const ACCESS_TOKEN = token();
|
||||
|
||||
function panelJS() {
|
||||
return `
|
||||
var ha = document.querySelector('home-assistant');
|
||||
var main = ha && ha.shadowRoot && ha.shadowRoot.querySelector('home-assistant-main');
|
||||
var drawer = main && main.shadowRoot && main.shadowRoot.querySelector('ha-drawer');
|
||||
var resolver = drawer && drawer.querySelector('partial-panel-resolver');
|
||||
var custom = resolver && resolver.querySelector('ha-panel-custom');
|
||||
var p = custom && custom.querySelector('maintenance-supporter-panel');
|
||||
`;
|
||||
}
|
||||
|
||||
async function login(page) {
|
||||
await page.goto(HA);
|
||||
await page.waitForTimeout(1000);
|
||||
await page.evaluate((t) => {
|
||||
localStorage.setItem("hassTokens", JSON.stringify({
|
||||
hassUrl: "http://homeassistant-dev:8123",
|
||||
clientId: "http://homeassistant-dev:8123/",
|
||||
refresh_token: "",
|
||||
access_token: t,
|
||||
token_type: "Bearer",
|
||||
expires_in: 86400,
|
||||
expires: Date.now() + 86400000,
|
||||
}));
|
||||
}, ACCESS_TOKEN);
|
||||
await page.goto(HA + "/maintenance-supporter");
|
||||
await page.waitForTimeout(8000);
|
||||
}
|
||||
|
||||
async function setEnglishDark(page) {
|
||||
// The set_user_data round-trip occasionally crashes the page (HA frontend
|
||||
// reloads itself when language changes). Wrap in try/catch — if the page
|
||||
// crashed, the caller will recreate it via the recovery branch below.
|
||||
try {
|
||||
await page.evaluate(async () => {
|
||||
const ha = document.querySelector("home-assistant");
|
||||
const hass = ha && (ha.__hass || ha.hass);
|
||||
if (hass && hass.connection) {
|
||||
await hass.connection.sendMessagePromise({
|
||||
type: "frontend/set_user_data", key: "language",
|
||||
value: { language: "en", number_format: "language" },
|
||||
});
|
||||
await hass.connection.sendMessagePromise({
|
||||
type: "frontend/set_user_data", key: "core",
|
||||
value: { selectedTheme: { theme: "default", dark: true } },
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(" (language/theme change crashed, recovering)");
|
||||
return false;
|
||||
}
|
||||
await page.goto(HA + "/maintenance-supporter");
|
||||
await page.waitForTimeout(8000);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function shot(page, name) {
|
||||
const filePath = path.join(OUTPUT, name);
|
||||
await page.screenshot({ path: filePath });
|
||||
console.log(` ${name}`);
|
||||
}
|
||||
|
||||
// ─── Setup helpers ────────────────────────────────────────────────────
|
||||
|
||||
async function getObjects(page) {
|
||||
const raw = await page.evaluate(`{
|
||||
${panelJS()}
|
||||
var result = [];
|
||||
if (p && p._objects) {
|
||||
for (var i = 0; i < p._objects.length; i++) {
|
||||
var obj = p._objects[i];
|
||||
var tasks = [];
|
||||
if (obj.tasks) for (var j = 0; j < obj.tasks.length; j++) {
|
||||
tasks.push({ id: obj.tasks[j].id, name: obj.tasks[j].name });
|
||||
}
|
||||
result.push({ entry_id: obj.entry_id, name: obj.object.name, tasks: tasks });
|
||||
}
|
||||
}
|
||||
JSON.stringify(result);
|
||||
}`);
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
|
||||
async function setObjectDocUrl(page, entryId, url) {
|
||||
// v1.4.0 #43: set documentation_url on the object so the new
|
||||
// manual-link line renders in the object detail header (and via
|
||||
// v1.4.1 also on each task detail page belonging to this object).
|
||||
return await page.evaluate(async ({ entry_id, documentation_url }) => {
|
||||
const ha = document.querySelector("home-assistant");
|
||||
const hass = ha && (ha.__hass || ha.hass);
|
||||
return await hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/object/update",
|
||||
entry_id, documentation_url,
|
||||
});
|
||||
}, { entry_id: entryId, documentation_url: url });
|
||||
}
|
||||
|
||||
async function setNotificationTitleStyle(page, style) {
|
||||
// v1.4.0 #44: flip the global setting so the dropdown screenshot
|
||||
// shows it pre-selected (rather than the boring default).
|
||||
return await page.evaluate(async ({ s }) => {
|
||||
const ha = document.querySelector("home-assistant");
|
||||
const hass = ha && (ha.__hass || ha.hass);
|
||||
return await hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/global/update",
|
||||
settings: { notification_title_style: s, notifications_enabled: true },
|
||||
});
|
||||
}, { s: style });
|
||||
}
|
||||
|
||||
// ─── Main ─────────────────────────────────────────────────────────────
|
||||
|
||||
const browser = await chromium.connect("ws://localhost:3000");
|
||||
console.log("Connected to Playwright server\n");
|
||||
|
||||
const ctx = await browser.newContext({
|
||||
viewport: { width: 1280, height: 900 },
|
||||
locale: "en",
|
||||
colorScheme: "dark",
|
||||
});
|
||||
let page = await ctx.newPage();
|
||||
|
||||
console.log("Login + setup …");
|
||||
await login(page);
|
||||
const langOk = await setEnglishDark(page);
|
||||
if (!langOk) {
|
||||
try { await page.close(); } catch { /* ignore */ }
|
||||
page = await ctx.newPage();
|
||||
await login(page);
|
||||
}
|
||||
|
||||
console.log("\nPick a target object …");
|
||||
const objects = await getObjects(page);
|
||||
const target = objects.find(o => o.name === "HVAC System")
|
||||
|| objects.find(o => o.tasks.length > 0)
|
||||
|| objects[0];
|
||||
if (!target) {
|
||||
console.error("No objects in demo");
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(` using: ${target.name} (${target.tasks.length} tasks)`);
|
||||
|
||||
console.log("\nSet documentation_url on the object …");
|
||||
const r1 = await setObjectDocUrl(page, target.entry_id,
|
||||
"https://www.energystar.gov/products/hvac/maintenance-guide.pdf");
|
||||
console.log(" result:", JSON.stringify(r1));
|
||||
|
||||
console.log("\nFlip notification_title_style to object_name + enable notifications …");
|
||||
const r2 = await setNotificationTitleStyle(page, "object_name");
|
||||
console.log(" flag result keys:", Object.keys(r2 || {}));
|
||||
|
||||
// Reload so the panel picks up the changes
|
||||
await page.goto(HA + "/maintenance-supporter");
|
||||
await page.waitForTimeout(6000);
|
||||
|
||||
// ─── Screenshot 1: object detail with the new documentation URL link ──
|
||||
console.log("\n1) object-detail-with-manual.png");
|
||||
await page.evaluate(`{
|
||||
${panelJS()}
|
||||
if (p) p._showObject('${target.entry_id}');
|
||||
}`);
|
||||
await page.waitForTimeout(2500);
|
||||
await shot(page, "object-detail-with-manual.png");
|
||||
|
||||
// ─── Screenshot 2: task detail with parent object's manual link ──────
|
||||
console.log("\n2) task-detail-with-manual.png");
|
||||
if (target.tasks.length > 0) {
|
||||
const task = target.tasks[0];
|
||||
await page.evaluate(`{
|
||||
${panelJS()}
|
||||
if (p) p._showTask('${target.entry_id}', '${task.id}');
|
||||
}`);
|
||||
await page.waitForTimeout(3500);
|
||||
await shot(page, "task-detail-with-manual.png");
|
||||
} else {
|
||||
console.log(" skipped — target object has no tasks");
|
||||
}
|
||||
|
||||
// ─── Screenshot 3: notification title style dropdown in Settings ─────
|
||||
console.log("\n3) settings-notification-title-style.png");
|
||||
await page.evaluate(`{
|
||||
${panelJS()}
|
||||
if (p) {
|
||||
p._view = 'overview';
|
||||
p._overviewTab = 'settings';
|
||||
p.requestUpdate();
|
||||
}
|
||||
}`);
|
||||
await page.waitForTimeout(3500);
|
||||
// Scroll the notification settings section into view
|
||||
await page.evaluate(`{
|
||||
${panelJS()}
|
||||
if (p) {
|
||||
var sv = p.shadowRoot && p.shadowRoot.querySelector('maintenance-settings-view');
|
||||
if (sv && sv.shadowRoot) {
|
||||
// Try to find the title-style row
|
||||
var rows = sv.shadowRoot.querySelectorAll('.notif-row, .row, [data-key=notification_title_style]');
|
||||
// Fallback: scroll the settings root into the middle of the viewport
|
||||
var anchor = sv.shadowRoot.querySelector('select[data-key=notification_title_style]')
|
||||
|| sv.shadowRoot.querySelector('[data-key=notification_title_style]')
|
||||
|| rows[0];
|
||||
if (anchor) anchor.scrollIntoView({behavior: 'instant', block: 'center'});
|
||||
}
|
||||
}
|
||||
}`);
|
||||
await page.waitForTimeout(1500);
|
||||
await shot(page, "settings-notification-title-style.png");
|
||||
|
||||
// ─── Screenshot 4: refreshed overview (just dashboard tab) ────────────
|
||||
console.log("\n4) overview-v142.png");
|
||||
await page.evaluate(`{
|
||||
${panelJS()}
|
||||
if (p) {
|
||||
p._view = 'overview';
|
||||
p._overviewTab = 'dashboard';
|
||||
p.requestUpdate();
|
||||
}
|
||||
}`);
|
||||
await page.waitForTimeout(2500);
|
||||
await shot(page, "overview-v142.png");
|
||||
|
||||
console.log("\nDone.");
|
||||
await browser.close();
|
||||
+271
@@ -0,0 +1,271 @@
|
||||
/** v2.4.0 — Interactive Budget Section Card.
|
||||
*
|
||||
* Replaces the read-only markdown budget card. Lets the admin set
|
||||
* monthly_budget / yearly_budget inline. Read-only progress bars for
|
||||
* current spending. Uses ``maintenance_supporter/global/update`` with
|
||||
* the budget_monthly / budget_yearly settings keys.
|
||||
*/
|
||||
|
||||
import { LitElement, html, css, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { t, ensureLocale, DEFAULT_CURRENCY_SYMBOL } from "../styles";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { sectionCardSharedStyles } from "./section-card-shared-styles";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
interface BudgetStatus {
|
||||
monthly_budget?: number;
|
||||
monthly_spent?: number;
|
||||
yearly_budget?: number;
|
||||
yearly_spent?: number;
|
||||
currency_symbol?: string;
|
||||
}
|
||||
|
||||
interface CardConfig { type: string; title?: string; }
|
||||
|
||||
export class MaintenanceBudgetSectionCard extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@state() private _config: CardConfig = { type: "" };
|
||||
@state() private _status: BudgetStatus | null = null;
|
||||
@state() private _busy = false;
|
||||
@state() private _error = "";
|
||||
@state() private _localMonthly = "";
|
||||
@state() private _localYearly = "";
|
||||
@state() private _dirty = false;
|
||||
|
||||
private _loaded = false;
|
||||
|
||||
setConfig(config: CardConfig): void {
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
getCardSize(): number {
|
||||
return 2;
|
||||
}
|
||||
|
||||
private get _lang(): string {
|
||||
return this.hass?.language || "en";
|
||||
}
|
||||
|
||||
private get _isAdmin(): boolean {
|
||||
return (this.hass?.user?.is_admin ?? true) as boolean;
|
||||
}
|
||||
|
||||
updated(changedProps: Map<string, unknown>): void {
|
||||
super.updated(changedProps);
|
||||
if (changedProps.has("hass") && this.hass && !this._loaded) {
|
||||
this._loaded = true;
|
||||
void this._load();
|
||||
void ensureLocale(this._lang).then(() => this.requestUpdate());
|
||||
}
|
||||
}
|
||||
|
||||
private async _load(): Promise<void> {
|
||||
try {
|
||||
const r = await this.hass.connection.sendMessagePromise<BudgetStatus>({
|
||||
type: "maintenance_supporter/budget_status",
|
||||
});
|
||||
this._status = r;
|
||||
this._localMonthly = r.monthly_budget ? String(r.monthly_budget) : "";
|
||||
this._localYearly = r.yearly_budget ? String(r.yearly_budget) : "";
|
||||
this._dirty = false;
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
}
|
||||
}
|
||||
|
||||
private async _save(): Promise<void> {
|
||||
if (!this._isAdmin) return;
|
||||
this._busy = true;
|
||||
this._error = "";
|
||||
try {
|
||||
const m = parseFloat(this._localMonthly);
|
||||
const y = parseFloat(this._localYearly);
|
||||
const settings: Record<string, number> = {};
|
||||
if (!isNaN(m) && m >= 0) settings.budget_monthly = m;
|
||||
if (!isNaN(y) && y >= 0) settings.budget_yearly = y;
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/global/update",
|
||||
settings,
|
||||
});
|
||||
await this._load();
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _onDeepLink(): void {
|
||||
const path = "/maintenance-supporter?ms_action=open_budget";
|
||||
history.pushState(null, "", path);
|
||||
window.dispatchEvent(new CustomEvent("location-changed"));
|
||||
}
|
||||
|
||||
render() {
|
||||
const L = this._lang;
|
||||
const s = this._status;
|
||||
if (!s) {
|
||||
return html`<ha-card><div class="loading">${t("loading", L) || "Loading…"}</div></ha-card>`;
|
||||
}
|
||||
const sym = s.currency_symbol || DEFAULT_CURRENCY_SYMBOL;
|
||||
const mPct = s.monthly_budget ? Math.min(100, ((s.monthly_spent || 0) / s.monthly_budget) * 100) : 0;
|
||||
const yPct = s.yearly_budget ? Math.min(100, ((s.yearly_spent || 0) / s.yearly_budget) * 100) : 0;
|
||||
const mWarn = mPct >= 100 ? "danger" : mPct >= 80 ? "warning" : "ok";
|
||||
const yWarn = yPct >= 100 ? "danger" : yPct >= 80 ? "warning" : "ok";
|
||||
|
||||
return html`
|
||||
<ha-card>
|
||||
<div class="card-content">
|
||||
<div class="header">
|
||||
<div class="title">
|
||||
<span class="emoji">💰</span>
|
||||
<span>${this._config.title || t("settings_budget", L) || "Budget"}</span>
|
||||
</div>
|
||||
<span class="currency">${sym}</span>
|
||||
</div>
|
||||
|
||||
${this._error ? html`<div class="error">${this._error}</div>` : nothing}
|
||||
|
||||
<div class="track">
|
||||
<div class="track-label-row">
|
||||
<label>${t("budget_monthly", L) || "Monthly"}</label>
|
||||
<span class="track-numbers ${mWarn}">
|
||||
${(s.monthly_spent || 0).toFixed(0)} / ${(s.monthly_budget || 0).toFixed(0)} ${sym}
|
||||
</span>
|
||||
</div>
|
||||
<div class="bar"><div class="bar-fill ${mWarn}" style="width:${mPct}%"></div></div>
|
||||
</div>
|
||||
|
||||
<div class="track">
|
||||
<div class="track-label-row">
|
||||
<label>${t("budget_yearly", L) || "Yearly"}</label>
|
||||
<span class="track-numbers ${yWarn}">
|
||||
${(s.yearly_spent || 0).toFixed(0)} / ${(s.yearly_budget || 0).toFixed(0)} ${sym}
|
||||
</span>
|
||||
</div>
|
||||
<div class="bar"><div class="bar-fill ${yWarn}" style="width:${yPct}%"></div></div>
|
||||
</div>
|
||||
|
||||
${this._isAdmin
|
||||
? html`
|
||||
<div class="inputs-row">
|
||||
<div class="input-field">
|
||||
<label>${t("budget_monthly_set", L) || "Set monthly"}</label>
|
||||
<div class="input-wrap">
|
||||
<input type="number" min="0" step="1"
|
||||
.value=${this._localMonthly}
|
||||
?disabled=${this._busy}
|
||||
@input=${(e: Event) => {
|
||||
this._localMonthly = (e.target as HTMLInputElement).value;
|
||||
this._dirty = true;
|
||||
}} />
|
||||
<span class="input-suffix">${sym}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-field">
|
||||
<label>${t("budget_yearly_set", L) || "Set yearly"}</label>
|
||||
<div class="input-wrap">
|
||||
<input type="number" min="0" step="1"
|
||||
.value=${this._localYearly}
|
||||
?disabled=${this._busy}
|
||||
@input=${(e: Event) => {
|
||||
this._localYearly = (e.target as HTMLInputElement).value;
|
||||
this._dirty = true;
|
||||
}} />
|
||||
<span class="input-suffix">${sym}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn ${this._dirty ? "primary" : "muted"}"
|
||||
@click=${this._save}
|
||||
?disabled=${this._busy || !this._dirty}>
|
||||
<ha-icon icon="${this._dirty ? "mdi:content-save" : "mdi:check"}"></ha-icon>
|
||||
${this._dirty
|
||||
? (t("save", L) || "Save")
|
||||
: (t("saved", L) || "Saved")}
|
||||
</button>
|
||||
<button class="btn link" @click=${this._onDeepLink}>
|
||||
${t("budget_advanced", L) || "Currency, alerts…"}
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<button class="btn link" @click=${this._onDeepLink}>
|
||||
${t("budget_open_panel", L) || "Open in panel"}
|
||||
</button>
|
||||
`}
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = [sectionCardSharedStyles, css`
|
||||
.currency {
|
||||
font-size: 14px; font-weight: 600;
|
||||
color: var(--secondary-text-color);
|
||||
background: var(--secondary-background-color);
|
||||
padding: 2px 10px; border-radius: 999px;
|
||||
}
|
||||
.track { display: flex; flex-direction: column; gap: 4px; }
|
||||
.track-label-row {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
}
|
||||
.track-label-row label {
|
||||
font-size: 12px; color: var(--secondary-text-color);
|
||||
text-transform: uppercase; letter-spacing: 0.5px;
|
||||
}
|
||||
.track-numbers { font-size: 13px; font-weight: 600; }
|
||||
.track-numbers.ok { color: var(--primary-text-color); }
|
||||
.track-numbers.warning { color: #ff9800; }
|
||||
.track-numbers.danger { color: var(--error-color, #f44336); }
|
||||
.bar {
|
||||
height: 6px; background: var(--secondary-background-color);
|
||||
border-radius: 3px; overflow: hidden;
|
||||
}
|
||||
.bar-fill { height: 100%; transition: width 0.3s; border-radius: 3px; }
|
||||
.bar-fill.ok { background: var(--primary-color); }
|
||||
.bar-fill.warning { background: #ff9800; }
|
||||
.bar-fill.danger { background: var(--error-color, #f44336); }
|
||||
.inputs-row {
|
||||
display: grid; grid-template-columns: 1fr 1fr; gap: 8px;
|
||||
padding-top: 4px; border-top: 1px solid var(--divider-color);
|
||||
}
|
||||
.input-field { display: flex; flex-direction: column; gap: 4px; }
|
||||
.input-field label {
|
||||
font-size: 11px; color: var(--secondary-text-color);
|
||||
text-transform: uppercase; letter-spacing: 0.3px;
|
||||
}
|
||||
.input-wrap { position: relative; display: flex; align-items: center; }
|
||||
.input-wrap input {
|
||||
flex: 1; padding: 6px 32px 6px 8px; font-size: 13px;
|
||||
background: var(--secondary-background-color, #2c2c2c);
|
||||
color: var(--primary-text-color);
|
||||
border: 1px solid var(--divider-color); border-radius: 6px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.input-suffix {
|
||||
position: absolute; right: 8px;
|
||||
color: var(--secondary-text-color); font-size: 13px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.actions { display: flex; gap: 8px; align-items: center; }
|
||||
`];
|
||||
}
|
||||
|
||||
if (!customElements.get("maintenance-budget-section-card")) {
|
||||
customElements.define(
|
||||
"maintenance-budget-section-card",
|
||||
MaintenanceBudgetSectionCard,
|
||||
);
|
||||
}
|
||||
|
||||
(window as { customCards?: unknown[] }).customCards =
|
||||
(window as { customCards?: unknown[] }).customCards || [];
|
||||
((window as { customCards?: unknown[] }).customCards!).push({
|
||||
type: "maintenance-budget-section-card",
|
||||
name: "Maintenance Supporter — Budget",
|
||||
description: "Inline monthly + yearly budget editor",
|
||||
preview: false,
|
||||
});
|
||||
@@ -0,0 +1,396 @@
|
||||
/** Dialog for completing a maintenance task with optional notes, cost, duration. */
|
||||
|
||||
import { LitElement, html, css, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { t } from "../styles";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
|
||||
export class MaintenanceCompleteDialog extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@property() public entryId = "";
|
||||
@property() public taskId = "";
|
||||
@property() public taskName = "";
|
||||
@property() public lang = "en";
|
||||
@property({ type: Array }) public checklist: string[] = [];
|
||||
@property({ type: Boolean }) public adaptiveEnabled = false;
|
||||
// v2.20 (#83): task type + unit drive the reading-value field below.
|
||||
@property() public taskType = "";
|
||||
@property() public readingUnit = "";
|
||||
@state() private _open = false;
|
||||
@state() private _notes = "";
|
||||
@state() private _cost = "";
|
||||
@state() private _duration = "";
|
||||
@state() private _loading = false;
|
||||
@state() private _error = "";
|
||||
@state() private _checklistState: Record<string, boolean> = {};
|
||||
@state() private _feedback: string = "needed";
|
||||
@state() private _photoDocId = "";
|
||||
@state() private _photoPreview = "";
|
||||
@state() private _photoUploading = false;
|
||||
@state() private _readingValue = "";
|
||||
|
||||
public open(): void {
|
||||
if (this._open) return;
|
||||
this._open = true;
|
||||
this._notes = "";
|
||||
this._cost = "";
|
||||
this._duration = "";
|
||||
this._error = "";
|
||||
this._checklistState = {};
|
||||
this._feedback = "needed";
|
||||
this._photoDocId = "";
|
||||
this._photoPreview = "";
|
||||
this._photoUploading = false;
|
||||
this._readingValue = "";
|
||||
}
|
||||
|
||||
private _toggleCheck(idx: number): void {
|
||||
const key = String(idx);
|
||||
this._checklistState = {
|
||||
...this._checklistState,
|
||||
[key]: !this._checklistState[key],
|
||||
};
|
||||
}
|
||||
|
||||
private _setFeedback(value: string): void {
|
||||
this._feedback = value;
|
||||
}
|
||||
|
||||
private async _onPhotoInput(e: Event): Promise<void> {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = ""; // allow re-picking the same file
|
||||
if (!file) return;
|
||||
this._photoUploading = true;
|
||||
this._error = "";
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append("entry_id", this.entryId);
|
||||
form.append("tags", "photo");
|
||||
form.append("file", file, file.name);
|
||||
const resp = await fetch("/api/maintenance_supporter/document/upload", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${this.hass.auth?.data?.access_token ?? ""}` },
|
||||
body: form,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
this._error = resp.status === 413
|
||||
? t("doc_too_large", this.lang)
|
||||
: t("doc_upload_failed", this.lang);
|
||||
return;
|
||||
}
|
||||
const doc = (await resp.json()) as { id?: string };
|
||||
if (doc.id) {
|
||||
this._photoDocId = doc.id;
|
||||
this._photoPreview = URL.createObjectURL(file);
|
||||
}
|
||||
} catch {
|
||||
this._error = t("doc_upload_failed", this.lang);
|
||||
} finally {
|
||||
this._photoUploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _removePhoto(): void {
|
||||
if (this._photoPreview) URL.revokeObjectURL(this._photoPreview);
|
||||
this._photoDocId = "";
|
||||
this._photoPreview = "";
|
||||
}
|
||||
|
||||
private async _complete(): Promise<void> {
|
||||
this._loading = true;
|
||||
this._error = "";
|
||||
try {
|
||||
const data: Record<string, unknown> = {
|
||||
type: "maintenance_supporter/task/complete",
|
||||
entry_id: this.entryId,
|
||||
task_id: this.taskId,
|
||||
};
|
||||
if (this._notes) data.notes = this._notes;
|
||||
if (this._cost) {
|
||||
const cost = parseFloat(this._cost);
|
||||
if (!isNaN(cost) && cost >= 0) data.cost = cost;
|
||||
}
|
||||
if (this._duration) {
|
||||
const dur = parseInt(this._duration, 10);
|
||||
if (!isNaN(dur) && dur >= 0) data.duration = dur;
|
||||
}
|
||||
if (this.checklist.length > 0) {
|
||||
data.checklist_state = this._checklistState;
|
||||
}
|
||||
if (this.adaptiveEnabled) {
|
||||
data.feedback = this._feedback;
|
||||
}
|
||||
if (this._photoDocId) {
|
||||
data.photo_doc_id = this._photoDocId;
|
||||
}
|
||||
if (this._readingValue !== "") {
|
||||
const rv = parseFloat(this._readingValue);
|
||||
if (!isNaN(rv)) data.reading_value = rv;
|
||||
}
|
||||
await this.hass.connection.sendMessagePromise(data);
|
||||
this._open = false;
|
||||
this.dispatchEvent(new CustomEvent("task-completed"));
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this.lang, t("save_error", this.lang));
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _close(): void {
|
||||
this._open = false;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this._open) return html``;
|
||||
const L = this.lang || this.hass?.language || "en";
|
||||
return html`
|
||||
<ha-dialog open @closed=${this._close}>
|
||||
<div class="dialog-title">${t("complete_title", L)}${this.taskName}</div>
|
||||
<div class="content">
|
||||
${this._error ? html`<div class="error">${this._error}</div>` : nothing}
|
||||
${this.checklist.length > 0 ? html`
|
||||
<div class="checklist-section">
|
||||
<label class="checklist-label">${t("checklist", L)}</label>
|
||||
${this.checklist.map((item, idx) => html`
|
||||
<label class="checklist-item" @click=${() => this._toggleCheck(idx)}>
|
||||
<input type="checkbox" .checked=${!!this._checklistState[String(idx)]} />
|
||||
<span>${item}</span>
|
||||
</label>
|
||||
`)}
|
||||
</div>
|
||||
` : nothing}
|
||||
${this.taskType === "reading"
|
||||
? html`
|
||||
<label class="field">
|
||||
<span class="field-label">${t("reading_value_label", L)}${this.readingUnit ? ` (${this.readingUnit})` : ""}</span>
|
||||
<input type="number" step="any" class="field-input"
|
||||
.value=${this._readingValue}
|
||||
@input=${(e: Event) => (this._readingValue = (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
|
||||
need it) so the elements render with zero height and the user
|
||||
only sees the title + Cancel/Complete buttons — the original
|
||||
bug report. Native inputs always render. -->
|
||||
<label class="field">
|
||||
<span class="field-label">${t("notes_optional", L)}</span>
|
||||
<input type="text" class="field-input"
|
||||
.value=${this._notes}
|
||||
@input=${(e: Event) => (this._notes = (e.target as HTMLInputElement).value)} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">${t("cost_optional", L)}</span>
|
||||
<input type="number" step="0.01" min="0" class="field-input"
|
||||
.value=${this._cost}
|
||||
@input=${(e: Event) => (this._cost = (e.target as HTMLInputElement).value)} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">${t("duration_minutes", L)}</span>
|
||||
<input type="number" step="1" min="0" class="field-input"
|
||||
.value=${this._duration}
|
||||
@input=${(e: Event) => (this._duration = (e.target as HTMLInputElement).value)} />
|
||||
</label>
|
||||
<div class="field">
|
||||
<span class="field-label">${t("completion_photo_optional", L)}</span>
|
||||
${this._photoPreview
|
||||
? html`
|
||||
<div class="photo-preview">
|
||||
<img src=${this._photoPreview} alt="" />
|
||||
<button type="button" class="photo-remove" @click=${this._removePhoto}
|
||||
title="${t("remove", L)}">✕</button>
|
||||
</div>`
|
||||
: html`
|
||||
<label class="photo-pick">
|
||||
<ha-icon icon="mdi:camera"></ha-icon>
|
||||
<span>${this._photoUploading ? t("uploading", L) : t("add_photo", L)}</span>
|
||||
<input type="file" accept="image/*" capture="environment"
|
||||
?disabled=${this._photoUploading}
|
||||
@change=${this._onPhotoInput} />
|
||||
</label>`}
|
||||
</div>
|
||||
${this.adaptiveEnabled ? html`
|
||||
<div class="feedback-section">
|
||||
<label class="feedback-label">${t("was_maintenance_needed", L)}</label>
|
||||
<div class="feedback-buttons">
|
||||
<button
|
||||
class="feedback-btn ${this._feedback === "needed" ? "selected" : ""}"
|
||||
@click=${() => this._setFeedback("needed")}
|
||||
>${t("feedback_needed", L)}</button>
|
||||
<button
|
||||
class="feedback-btn ${this._feedback === "not_needed" ? "selected" : ""}"
|
||||
@click=${() => this._setFeedback("not_needed")}
|
||||
>${t("feedback_not_needed", L)}</button>
|
||||
<button
|
||||
class="feedback-btn ${this._feedback === "not_sure" ? "selected" : ""}"
|
||||
@click=${() => this._setFeedback("not_sure")}
|
||||
>${t("feedback_not_sure", L)}</button>
|
||||
</div>
|
||||
</div>
|
||||
` : nothing}
|
||||
</div>
|
||||
<div class="dialog-actions">
|
||||
<ha-button appearance="plain" @click=${this._close}>
|
||||
${t("cancel", L)}
|
||||
</ha-button>
|
||||
<ha-button
|
||||
@click=${this._complete}
|
||||
.disabled=${this._loading}
|
||||
>
|
||||
${this._loading ? t("completing", L) : t("complete", L)}
|
||||
</ha-button>
|
||||
</div>
|
||||
</ha-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
.dialog-title {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-width: 300px;
|
||||
}
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding-top: 16px;
|
||||
}
|
||||
.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);
|
||||
}
|
||||
.photo-pick {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border: 1px dashed var(--divider-color);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--secondary-text-color);
|
||||
width: fit-content;
|
||||
}
|
||||
.photo-pick:hover { border-color: var(--primary-color); }
|
||||
.photo-pick input[type="file"] { display: none; }
|
||||
.photo-preview {
|
||||
position: relative;
|
||||
width: fit-content;
|
||||
}
|
||||
.photo-preview img {
|
||||
max-width: 160px;
|
||||
max-height: 160px;
|
||||
border-radius: 8px;
|
||||
display: block;
|
||||
}
|
||||
.photo-remove {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: var(--error-color, #db4437);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
.checklist-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.checklist-label {
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.checklist-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
padding: 4px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
.checklist-item input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.feedback-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
border-top: 1px solid var(--divider-color);
|
||||
}
|
||||
.feedback-label {
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.feedback-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.feedback-btn {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: 8px;
|
||||
background: var(--card-background-color, #fff);
|
||||
color: var(--primary-text-color);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.feedback-btn:hover {
|
||||
background: var(--secondary-background-color, #f5f5f5);
|
||||
}
|
||||
.feedback-btn.selected {
|
||||
background: var(--primary-color);
|
||||
color: var(--text-primary-color, #fff);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
// Safe registration — avoids duplicate define when both panel and card load
|
||||
if (!customElements.get("maintenance-complete-dialog")) {
|
||||
customElements.define("maintenance-complete-dialog", MaintenanceCompleteDialog);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/** Reusable confirmation dialog wrapping <ha-dialog>. */
|
||||
|
||||
import { LitElement, html, css, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { t } from "../styles";
|
||||
|
||||
export interface ConfirmOptions {
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
danger?: boolean;
|
||||
}
|
||||
|
||||
export interface PromptOptions extends ConfirmOptions {
|
||||
inputLabel?: string;
|
||||
inputType?: string; // "text" | "date" etc.
|
||||
inputValue?: string;
|
||||
}
|
||||
|
||||
export interface PromptResult {
|
||||
confirmed: boolean;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export class MaintenanceConfirmDialog extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _open = false;
|
||||
@state() private _title = "";
|
||||
@state() private _message = "";
|
||||
@state() private _confirmText = "";
|
||||
@state() private _danger = false;
|
||||
@state() private _inputLabel = "";
|
||||
@state() private _inputType = "";
|
||||
@state() private _inputValue = "";
|
||||
|
||||
private _resolve: ((value: boolean) => void) | null = null;
|
||||
private _promptResolve: ((value: PromptResult) => void) | null = null;
|
||||
|
||||
public confirm(opts: ConfirmOptions): Promise<boolean> {
|
||||
this._title = opts.title;
|
||||
this._message = opts.message;
|
||||
this._confirmText = opts.confirmText || "OK";
|
||||
this._danger = opts.danger || false;
|
||||
this._inputLabel = "";
|
||||
this._inputType = "";
|
||||
this._inputValue = "";
|
||||
this._open = true;
|
||||
return new Promise<boolean>((resolve) => {
|
||||
this._resolve = resolve;
|
||||
this._promptResolve = null;
|
||||
});
|
||||
}
|
||||
|
||||
public prompt(opts: PromptOptions): Promise<PromptResult> {
|
||||
this._title = opts.title;
|
||||
this._message = opts.message;
|
||||
this._confirmText = opts.confirmText || "OK";
|
||||
this._danger = opts.danger || false;
|
||||
this._inputLabel = opts.inputLabel || "";
|
||||
this._inputType = opts.inputType || "text";
|
||||
this._inputValue = opts.inputValue || "";
|
||||
this._open = true;
|
||||
return new Promise<PromptResult>((resolve) => {
|
||||
this._promptResolve = resolve;
|
||||
this._resolve = null;
|
||||
});
|
||||
}
|
||||
|
||||
private _cancel(): void {
|
||||
this._open = false;
|
||||
if (this._promptResolve) {
|
||||
this._promptResolve({ confirmed: false, value: "" });
|
||||
this._promptResolve = null;
|
||||
}
|
||||
this._resolve?.(false);
|
||||
this._resolve = null;
|
||||
}
|
||||
|
||||
private _confirmAction(): void {
|
||||
this._open = false;
|
||||
if (this._promptResolve) {
|
||||
this._promptResolve({ confirmed: true, value: this._inputValue });
|
||||
this._promptResolve = null;
|
||||
}
|
||||
this._resolve?.(true);
|
||||
this._resolve = null;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this._open) return nothing;
|
||||
const lang = this.hass?.language || "en";
|
||||
return html`
|
||||
<ha-dialog open @closed=${this._cancel}>
|
||||
<div class="dialog-title">${this._title}</div>
|
||||
<div class="content">
|
||||
${this._message}
|
||||
${this._inputLabel ? html`
|
||||
<!-- Native <input> rather than <ha-textfield>: HA loads
|
||||
ha-textfield lazily for its own panels, so inside this custom
|
||||
panel it can be unregistered and render with zero height —
|
||||
the prompt then shows no field at all (caught live testing
|
||||
the pause/replace prompts; same fix as complete-dialog). -->
|
||||
<label class="field">
|
||||
<span class="field-label">${this._inputLabel}</span>
|
||||
<input class="field-input"
|
||||
type="${this._inputType || "text"}"
|
||||
.value=${this._inputValue}
|
||||
@input=${(e: Event) => (this._inputValue = (e.target as HTMLInputElement).value)} />
|
||||
</label>
|
||||
` : nothing}
|
||||
</div>
|
||||
<div class="dialog-actions">
|
||||
<ha-button appearance="plain" @click=${this._cancel}>
|
||||
${t("cancel", lang)}
|
||||
</ha-button>
|
||||
<ha-button
|
||||
class="${this._danger ? "danger" : ""}"
|
||||
@click=${this._confirmAction}
|
||||
>
|
||||
${this._confirmText}
|
||||
</ha-button>
|
||||
</div>
|
||||
</ha-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = 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); }
|
||||
.content {
|
||||
padding: 8px 0;
|
||||
min-width: 280px;
|
||||
line-height: 1.5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding-top: 16px;
|
||||
}
|
||||
ha-textfield {
|
||||
display: block;
|
||||
}
|
||||
ha-button.danger {
|
||||
--mdc-theme-primary: var(--error-color, #f44336);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
if (!customElements.get("maintenance-confirm-dialog")) {
|
||||
customElements.define("maintenance-confirm-dialog", MaintenanceConfirmDialog);
|
||||
}
|
||||
@@ -0,0 +1,643 @@
|
||||
/** Documents section for an object's detail view.
|
||||
*
|
||||
* Self-contained: lists an object's documents (files + web-links), uploads a
|
||||
* file (multipart POST to the authenticated view — a WS frame can't carry a
|
||||
* binary body), downloads via a signed path (`auth/sign_path` → Companion-safe
|
||||
* anchor), adds web-links, and deletes. Write actions are gated by `canWrite`
|
||||
* (the server enforces it too); download/open is available to everyone.
|
||||
*/
|
||||
|
||||
import { LitElement, html, css, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { t, ensureLocale } from "../styles";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { downloadUrl } from "../helpers/download";
|
||||
import { formatBytes } from "../helpers/format-bytes";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
interface MaintenanceDocument {
|
||||
id: string;
|
||||
kind: "file" | "weblink";
|
||||
title: string;
|
||||
filename?: string;
|
||||
url?: string;
|
||||
mime?: string;
|
||||
size?: number;
|
||||
tags?: string[];
|
||||
added_at?: string;
|
||||
}
|
||||
|
||||
const CATEGORIES = ["manual", "warranty", "invoice", "spare_parts", "photo", "other"] as const;
|
||||
const CATEGORY_ICONS: Record<string, string> = {
|
||||
manual: "mdi:book-open-variant",
|
||||
warranty: "mdi:shield-check",
|
||||
invoice: "mdi:receipt-text-outline",
|
||||
spare_parts: "mdi:cog-outline",
|
||||
photo: "mdi:image-outline",
|
||||
other: "mdi:file-document-outline",
|
||||
};
|
||||
|
||||
export class MaintenanceDocumentsSection extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@property({ attribute: false }) public entryId!: string;
|
||||
@property({ type: Boolean }) public canWrite = false;
|
||||
|
||||
@state() private _docs: MaintenanceDocument[] = [];
|
||||
@state() private _loaded = false;
|
||||
@state() private _busy = false;
|
||||
@state() private _error = "";
|
||||
@state() private _hint = "";
|
||||
@state() private _addingLink = false;
|
||||
@state() private _linkUrl = "";
|
||||
@state() private _linkTitle = "";
|
||||
@state() private _category = "manual";
|
||||
@state() private _thumbs: Record<string, string> = {};
|
||||
@state() private _lightboxUrl = "";
|
||||
@state() private _editingId = "";
|
||||
@state() private _editTitle = "";
|
||||
@state() private _editCategory = "manual";
|
||||
@state() private _dragOver = false;
|
||||
|
||||
private _loadedFor: string | null = null;
|
||||
private _localeReady = false;
|
||||
|
||||
private _isImage(doc: MaintenanceDocument): boolean {
|
||||
return doc.kind === "file" && (doc.mime || "").startsWith("image/");
|
||||
}
|
||||
|
||||
private async _sign(doc: MaintenanceDocument): Promise<string> {
|
||||
const signed = await this.hass.connection.sendMessagePromise<{ path: string }>({
|
||||
type: "auth/sign_path",
|
||||
path: `/api/maintenance_supporter/document/${doc.id}`,
|
||||
expires: 300,
|
||||
});
|
||||
return signed.path;
|
||||
}
|
||||
|
||||
private get _lang(): string {
|
||||
return this.hass?.language || "en";
|
||||
}
|
||||
|
||||
updated(changed: Map<string, unknown>): void {
|
||||
super.updated(changed);
|
||||
if (this.hass && !this._localeReady) {
|
||||
this._localeReady = true;
|
||||
// Re-render once the runtime locale JSON arrives (t() falls back to English
|
||||
// until then), so a first paint before the fetch lands still localizes.
|
||||
void ensureLocale(this._lang).then(() => this.requestUpdate());
|
||||
}
|
||||
if (this.hass && this.entryId && this._loadedFor !== this.entryId) {
|
||||
this._loadedFor = this.entryId;
|
||||
void this._load();
|
||||
}
|
||||
}
|
||||
|
||||
private async _load(): Promise<void> {
|
||||
try {
|
||||
const r = await this.hass.connection.sendMessagePromise<{ documents: MaintenanceDocument[] }>({
|
||||
type: "maintenance_supporter/documents/list",
|
||||
entry_id: this.entryId,
|
||||
});
|
||||
this._docs = r.documents || [];
|
||||
this._loaded = true;
|
||||
this._error = "";
|
||||
this._thumbs = {};
|
||||
void this._loadThumbs();
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
this._loaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Pre-sign a serve URL for each image doc so it can render as a thumbnail. */
|
||||
private async _loadThumbs(): Promise<void> {
|
||||
await Promise.all(
|
||||
this._docs.filter((d) => this._isImage(d)).map(async (d) => {
|
||||
try {
|
||||
const url = await this._sign(d);
|
||||
this._thumbs = { ...this._thumbs, [d.id]: url };
|
||||
} catch {
|
||||
/* leave the fallback icon */
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private _category_of(doc: MaintenanceDocument): string {
|
||||
const tag = (doc.tags || []).find((x) => (CATEGORIES as readonly string[]).includes(x));
|
||||
return tag || "other";
|
||||
}
|
||||
|
||||
/** Keyboard support for the file-picker <label>s (Enter/Space → open). */
|
||||
private _labelKeydown(e: KeyboardEvent): void {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
(e.currentTarget as HTMLElement).querySelector("input")?.click();
|
||||
}
|
||||
}
|
||||
|
||||
private _onFileInput(e: Event): void {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const files = Array.from(input.files ?? []);
|
||||
if (files.length) void this._uploadFiles(files);
|
||||
input.value = ""; // let the same file be re-picked
|
||||
}
|
||||
|
||||
private _onCameraInput(e: Event): void {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const files = Array.from(input.files ?? []);
|
||||
if (files.length) void this._uploadFiles(files, "photo");
|
||||
input.value = "";
|
||||
}
|
||||
|
||||
private _onDrop(e: DragEvent): void {
|
||||
e.preventDefault();
|
||||
this._dragOver = false;
|
||||
if (!this.canWrite || this._busy) return;
|
||||
const files = Array.from(e.dataTransfer?.files ?? []);
|
||||
if (files.length) void this._uploadFiles(files);
|
||||
}
|
||||
|
||||
private _onDragOver(e: DragEvent): void {
|
||||
if (!this.canWrite) return;
|
||||
e.preventDefault();
|
||||
this._dragOver = true;
|
||||
}
|
||||
|
||||
private _onDragLeave(e: DragEvent): void {
|
||||
// Only clear when the pointer truly leaves the zone (not when moving over a
|
||||
// child element), otherwise the overlay flickers.
|
||||
const rt = e.relatedTarget as Node | null;
|
||||
if (!rt || !(e.currentTarget as HTMLElement).contains(rt)) this._dragOver = false;
|
||||
}
|
||||
|
||||
private async _uploadFiles(files: File[], category?: string): Promise<void> {
|
||||
const cat = category ?? this._category;
|
||||
this._busy = true;
|
||||
this._error = "";
|
||||
this._hint = "";
|
||||
let deduped = 0;
|
||||
let dupInObject = 0;
|
||||
try {
|
||||
for (const file of files) {
|
||||
const form = new FormData();
|
||||
form.append("entry_id", this.entryId);
|
||||
form.append("tags", cat);
|
||||
form.append("file", file, file.name);
|
||||
const resp = await fetch("/api/maintenance_supporter/document/upload", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${this.hass.auth?.data?.access_token ?? ""}` },
|
||||
body: form,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
this._error = resp.status === 413 ? t("doc_too_large", this._lang) : t("doc_upload_failed", this._lang);
|
||||
continue;
|
||||
}
|
||||
const doc = (await resp.json()) as { deduped?: boolean; duplicate_in_object?: string | null };
|
||||
if (doc.duplicate_in_object) dupInObject++;
|
||||
else if (doc.deduped) deduped++;
|
||||
}
|
||||
if (dupInObject) this._hint = t("doc_dup_in_object", this._lang);
|
||||
else if (deduped) this._hint = t("doc_deduped", this._lang);
|
||||
await this._load();
|
||||
} catch {
|
||||
this._error = t("doc_upload_failed", this._lang);
|
||||
} finally {
|
||||
this._busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async _download(doc: MaintenanceDocument): Promise<void> {
|
||||
try {
|
||||
const signed = await this.hass.connection.sendMessagePromise<{ path: string }>({
|
||||
type: "auth/sign_path",
|
||||
path: `/api/maintenance_supporter/document/${doc.id}`,
|
||||
expires: 30,
|
||||
});
|
||||
downloadUrl(signed.path, doc.filename || doc.title || "document");
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
}
|
||||
}
|
||||
|
||||
/** Open a document for viewing: images in an in-app lightbox, everything else
|
||||
* inline in a new tab (the serve view sends Content-Disposition: inline). */
|
||||
private async _preview(doc: MaintenanceDocument): Promise<void> {
|
||||
if (this._isImage(doc)) {
|
||||
this._lightboxUrl = this._thumbs[doc.id] || (await this._sign(doc));
|
||||
return;
|
||||
}
|
||||
// Open the tab synchronously (in the click gesture) so it isn't popup-blocked,
|
||||
// then point it at the freshly signed URL once it resolves.
|
||||
const win = window.open("about:blank", "_blank");
|
||||
try {
|
||||
const url = await this._sign(doc);
|
||||
// Absolute URL so it always resolves against the blank popup (about:blank).
|
||||
if (win) win.location.href = new URL(url, window.location.origin).href;
|
||||
} catch (e) {
|
||||
if (win) win.close();
|
||||
this._error = describeWsError(e, this._lang);
|
||||
}
|
||||
}
|
||||
|
||||
/** Open a document from a title/row click (not just the small icons): preview
|
||||
* a file, open a web-link in a new tab. */
|
||||
private _openDoc(doc: MaintenanceDocument): void {
|
||||
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");
|
||||
}
|
||||
|
||||
private _startEdit(doc: MaintenanceDocument): void {
|
||||
this._editingId = doc.id;
|
||||
this._editTitle = doc.title || "";
|
||||
this._editCategory = this._category_of(doc);
|
||||
this._addingLink = false;
|
||||
this._error = "";
|
||||
}
|
||||
|
||||
private _cancelEdit(): void {
|
||||
this._editingId = "";
|
||||
}
|
||||
|
||||
private async _saveEdit(doc: MaintenanceDocument): Promise<void> {
|
||||
// Keep any free (non-category) tags; swap in the chosen category.
|
||||
const freeTags = (doc.tags || []).filter(
|
||||
(x) => !(CATEGORIES as readonly string[]).includes(x),
|
||||
);
|
||||
const tags = doc.kind === "file" ? [this._editCategory, ...freeTags] : (doc.tags ?? []);
|
||||
this._busy = true;
|
||||
this._error = "";
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/documents/update",
|
||||
doc_id: doc.id,
|
||||
title: this._editTitle.trim() || doc.filename || doc.url || "",
|
||||
tags,
|
||||
});
|
||||
this._editingId = "";
|
||||
await this._load();
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async _delete(doc: MaintenanceDocument): Promise<void> {
|
||||
const name = doc.title || doc.filename || doc.url || "";
|
||||
if (!window.confirm(t("doc_delete_confirm", this._lang).replace("{name}", name))) return;
|
||||
this._busy = true;
|
||||
this._error = "";
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/documents/delete",
|
||||
doc_id: doc.id,
|
||||
});
|
||||
await this._load();
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async _addLink(): Promise<void> {
|
||||
const url = this._linkUrl.trim();
|
||||
if (!url) return;
|
||||
this._busy = true;
|
||||
this._error = "";
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/documents/add_link",
|
||||
entry_id: this.entryId,
|
||||
url,
|
||||
title: this._linkTitle.trim() || null,
|
||||
});
|
||||
this._linkUrl = "";
|
||||
this._linkTitle = "";
|
||||
this._addingLink = false;
|
||||
await this._load();
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang, t("doc_link_invalid", this._lang));
|
||||
} finally {
|
||||
this._busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const L = this._lang;
|
||||
return html`
|
||||
<div
|
||||
class="doc-zone ${this._dragOver ? "drag-over" : ""}"
|
||||
@dragover=${this._onDragOver}
|
||||
@dragleave=${this._onDragLeave}
|
||||
@drop=${this._onDrop}
|
||||
>
|
||||
${this._dragOver && this.canWrite
|
||||
? html`<div class="drop-overlay">
|
||||
<ha-icon icon="mdi:tray-arrow-down"></ha-icon> ${t("doc_drop_hint", L)}
|
||||
</div>`
|
||||
: nothing}
|
||||
<div class="doc-header">
|
||||
<h3>${t("documents", L)} (${this._docs.length})</h3>
|
||||
${this.canWrite
|
||||
? html`
|
||||
<div class="doc-actions">
|
||||
<select
|
||||
class="cat-select"
|
||||
.value=${this._category}
|
||||
?disabled=${this._busy}
|
||||
@change=${(e: Event) => (this._category = (e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
${CATEGORIES.map((c) => html`<option value=${c}>${t(`doc_cat_${c}`, L)}</option>`)}
|
||||
</select>
|
||||
<label
|
||||
class="btn primary ${this._busy ? "disabled" : ""}"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@keydown=${this._labelKeydown}
|
||||
>
|
||||
<ha-icon icon="mdi:upload"></ha-icon>
|
||||
${this._busy ? t("doc_uploading", L) : t("doc_upload", L)}
|
||||
<input type="file" multiple hidden ?disabled=${this._busy} @change=${this._onFileInput} />
|
||||
</label>
|
||||
<label
|
||||
class="btn camera-btn ${this._busy ? "disabled" : ""}"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label=${t("doc_camera", L)}
|
||||
title=${t("doc_camera", L)}
|
||||
@keydown=${this._labelKeydown}
|
||||
>
|
||||
<ha-icon icon="mdi:camera"></ha-icon>
|
||||
<input type="file" accept="image/*" capture="environment" hidden ?disabled=${this._busy} @change=${this._onCameraInput} />
|
||||
</label>
|
||||
<button class="btn" ?disabled=${this._busy} @click=${() => (this._addingLink = !this._addingLink)}>
|
||||
<ha-icon icon="mdi:link-variant"></ha-icon> ${t("doc_add_link", L)}
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
|
||||
${this._error ? html`<div class="doc-msg error">${this._error}</div>` : nothing}
|
||||
${this._hint ? html`<div class="doc-msg hint">${this._hint}</div>` : nothing}
|
||||
|
||||
${this._addingLink && this.canWrite
|
||||
? html`
|
||||
<div class="link-form">
|
||||
<input
|
||||
type="url"
|
||||
placeholder=${t("doc_link_url", L)}
|
||||
.value=${this._linkUrl}
|
||||
?disabled=${this._busy}
|
||||
@input=${(e: Event) => (this._linkUrl = (e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder=${t("doc_link_title", L)}
|
||||
.value=${this._linkTitle}
|
||||
?disabled=${this._busy}
|
||||
@input=${(e: Event) => (this._linkTitle = (e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
<button class="btn primary" ?disabled=${this._busy || !this._linkUrl.trim()} @click=${this._addLink}>
|
||||
${t("add", L)}
|
||||
</button>
|
||||
<button class="btn" ?disabled=${this._busy} @click=${() => (this._addingLink = false)}>
|
||||
${t("cancel", L)}
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
|
||||
${!this._loaded
|
||||
? html`<div class="doc-empty">${t("loading", L)}</div>`
|
||||
: this._docs.length === 0
|
||||
? html`<div class="doc-empty">${t("documents_empty", L)}</div>`
|
||||
: html`
|
||||
<div class="doc-list">
|
||||
${this._docs.map((doc) => this._renderDoc(doc, L))}
|
||||
</div>
|
||||
`}
|
||||
|
||||
${this._lightboxUrl
|
||||
? html`<div class="lightbox" @click=${() => (this._lightboxUrl = "")}>
|
||||
<img class="lightbox-img" src=${this._lightboxUrl} @click=${(e: Event) => e.stopPropagation()} />
|
||||
<button class="lightbox-close" title=${t("doc_close", L)} @click=${() => (this._lightboxUrl = "")}>
|
||||
<ha-icon icon="mdi:close"></ha-icon>
|
||||
</button>
|
||||
</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderDoc(doc: MaintenanceDocument, L: string) {
|
||||
if (this._editingId === doc.id) return this._renderEdit(doc, L);
|
||||
const isFile = doc.kind === "file";
|
||||
const cat = this._category_of(doc);
|
||||
const meta = isFile
|
||||
? `${t(`doc_cat_${cat}`, L)} · ${formatBytes(doc.size)}`
|
||||
: t("doc_link_badge", L);
|
||||
const thumb = this._thumbs[doc.id];
|
||||
return html`
|
||||
<div class="doc-row">
|
||||
${isFile && thumb
|
||||
? html`<img
|
||||
class="doc-thumb"
|
||||
src=${thumb}
|
||||
alt=${doc.title || ""}
|
||||
title=${t("doc_open", L)}
|
||||
@click=${() => this._preview(doc)}
|
||||
/>`
|
||||
: html`<ha-icon
|
||||
class="doc-icon ${isFile ? "clickable" : ""}"
|
||||
icon=${isFile ? CATEGORY_ICONS[cat] : "mdi:link-variant"}
|
||||
@click=${() => isFile && this._preview(doc)}
|
||||
></ha-icon>`}
|
||||
<div
|
||||
class="doc-info"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
title=${t("doc_open", L)}
|
||||
@click=${() => this._openDoc(doc)}
|
||||
@keydown=${(e: KeyboardEvent) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
this._openDoc(doc);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="doc-title">${doc.title || doc.filename || doc.url}</div>
|
||||
<div class="doc-meta">${meta}</div>
|
||||
</div>
|
||||
<div class="doc-row-actions">
|
||||
${isFile
|
||||
? html`
|
||||
<button class="icon-btn" title=${t("doc_open", L)} @click=${() => this._preview(doc)}>
|
||||
<ha-icon icon="mdi:eye-outline"></ha-icon>
|
||||
</button>
|
||||
<button class="icon-btn" title=${t("doc_download", L)} @click=${() => this._download(doc)}>
|
||||
<ha-icon icon="mdi:download"></ha-icon>
|
||||
</button>`
|
||||
: html`<a
|
||||
class="icon-btn"
|
||||
href=${doc.url && /^https?:\/\//i.test(doc.url) ? doc.url : "#"}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title=${t("doc_open", L)}
|
||||
><ha-icon icon="mdi:open-in-new"></ha-icon></a>`}
|
||||
${this.canWrite
|
||||
? html`
|
||||
<button class="icon-btn" title=${t("edit", L)} ?disabled=${this._busy} @click=${() => this._startEdit(doc)}>
|
||||
<ha-icon icon="mdi:pencil"></ha-icon>
|
||||
</button>
|
||||
<button class="icon-btn danger" title=${t("delete", L)} ?disabled=${this._busy} @click=${() => this._delete(doc)}>
|
||||
<ha-icon icon="mdi:delete"></ha-icon>
|
||||
</button>`
|
||||
: nothing}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderEdit(doc: MaintenanceDocument, L: string) {
|
||||
const isFile = doc.kind === "file";
|
||||
return html`
|
||||
<div class="doc-row editing">
|
||||
<input
|
||||
class="edit-title"
|
||||
type="text"
|
||||
placeholder=${t("doc_link_title", L)}
|
||||
.value=${this._editTitle}
|
||||
?disabled=${this._busy}
|
||||
@input=${(e: Event) => (this._editTitle = (e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
${isFile
|
||||
? html`<select
|
||||
class="cat-select"
|
||||
?disabled=${this._busy}
|
||||
@change=${(e: Event) => (this._editCategory = (e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
${CATEGORIES.map(
|
||||
(c) => html`<option value=${c} ?selected=${c === this._editCategory}>${t(`doc_cat_${c}`, L)}</option>`,
|
||||
)}
|
||||
</select>`
|
||||
: nothing}
|
||||
<button class="icon-btn" title=${t("save", L)} ?disabled=${this._busy || !this._editTitle.trim()} @click=${() => this._saveEdit(doc)}>
|
||||
<ha-icon icon="mdi:check"></ha-icon>
|
||||
</button>
|
||||
<button class="icon-btn" title=${t("cancel", L)} ?disabled=${this._busy} @click=${this._cancelEdit}>
|
||||
<ha-icon icon="mdi:close"></ha-icon>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host { display: block; margin: 8px 0 4px; }
|
||||
.doc-zone { position: relative; }
|
||||
.doc-zone.drag-over {
|
||||
outline: 2px dashed var(--primary-color); outline-offset: 4px; border-radius: 8px;
|
||||
}
|
||||
.drop-overlay {
|
||||
position: absolute; inset: 0; z-index: 5; pointer-events: none;
|
||||
display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||
border-radius: 8px; font-size: 15px; font-weight: 600;
|
||||
color: var(--primary-color); opacity: 0.95;
|
||||
background: var(--card-background-color, rgba(255, 255, 255, 0.85));
|
||||
}
|
||||
.drop-overlay ha-icon { --mdc-icon-size: 24px; }
|
||||
.camera-btn { padding: 6px 10px; }
|
||||
.doc-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 12px; flex-wrap: wrap;
|
||||
}
|
||||
h3 { margin: 8px 0; font-size: 16px; }
|
||||
.doc-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.cat-select {
|
||||
padding: 6px 8px; border-radius: 6px; font: inherit;
|
||||
background: var(--secondary-background-color, rgba(0,0,0,0.06));
|
||||
color: var(--primary-text-color); border: 1px solid var(--divider-color);
|
||||
}
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: 6px; cursor: pointer;
|
||||
padding: 6px 12px; border-radius: 6px; font: inherit; font-size: 13px;
|
||||
background: var(--secondary-background-color, rgba(0,0,0,0.06));
|
||||
color: var(--primary-text-color); border: 1px solid var(--divider-color);
|
||||
}
|
||||
.btn.primary { background: var(--primary-color); color: var(--text-primary-color, #fff); border-color: var(--primary-color); }
|
||||
.btn:focus-visible, .icon-btn:focus-visible {
|
||||
outline: 2px solid var(--primary-color); outline-offset: 2px;
|
||||
}
|
||||
.btn.disabled, .btn[disabled] { opacity: 0.5; pointer-events: none; }
|
||||
.btn ha-icon { --mdc-icon-size: 18px; }
|
||||
.link-form { display: flex; gap: 8px; flex-wrap: wrap; margin: 8px 0; }
|
||||
.link-form input {
|
||||
flex: 1 1 180px; padding: 6px 10px; border-radius: 6px; font: inherit;
|
||||
background: var(--secondary-background-color, rgba(0,0,0,0.06));
|
||||
color: var(--primary-text-color); border: 1px solid var(--divider-color);
|
||||
}
|
||||
.doc-msg { font-size: 13px; margin: 6px 0; }
|
||||
.doc-msg.error { color: var(--error-color, #f44336); }
|
||||
.doc-msg.hint { color: var(--secondary-text-color, #888); }
|
||||
.doc-empty { color: var(--secondary-text-color, #888); font-size: 13px; padding: 8px 0; }
|
||||
.doc-list { display: flex; flex-direction: column; gap: 4px; }
|
||||
.doc-row {
|
||||
display: flex; align-items: center; gap: 12px; padding: 8px 10px;
|
||||
border: 1px solid var(--divider-color); border-radius: 8px;
|
||||
background: var(--card-background-color, transparent);
|
||||
}
|
||||
.doc-row.editing { gap: 8px; }
|
||||
.edit-title {
|
||||
flex: 1; min-width: 0; padding: 6px 10px; border-radius: 6px; font: inherit;
|
||||
background: var(--secondary-background-color, rgba(0, 0, 0, 0.06));
|
||||
color: var(--primary-text-color); border: 1px solid var(--divider-color);
|
||||
}
|
||||
.doc-icon { color: var(--primary-color); --mdc-icon-size: 24px; flex: none; }
|
||||
.doc-icon.clickable { cursor: pointer; }
|
||||
.doc-thumb {
|
||||
width: 40px; height: 40px; object-fit: cover; border-radius: 6px; flex: none;
|
||||
cursor: pointer; border: 1px solid var(--divider-color);
|
||||
background: var(--secondary-background-color, rgba(0,0,0,0.06));
|
||||
}
|
||||
.lightbox {
|
||||
position: fixed; inset: 0; z-index: 9999; cursor: zoom-out;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
.lightbox-img {
|
||||
max-width: 92vw; max-height: 92vh; object-fit: contain; cursor: default;
|
||||
border-radius: 8px; box-shadow: 0 8px 40px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.lightbox-close {
|
||||
position: fixed; top: 16px; right: 16px; cursor: pointer;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 44px; height: 44px; border-radius: 50%; border: none;
|
||||
background: rgba(0, 0, 0, 0.5); color: #fff;
|
||||
}
|
||||
.lightbox-close ha-icon { --mdc-icon-size: 26px; }
|
||||
.doc-info { flex: 1; min-width: 0; cursor: pointer; border-radius: 6px; }
|
||||
.doc-info:hover .doc-title { text-decoration: underline; }
|
||||
.doc-info:focus-visible { outline: 2px solid var(--primary-color); outline-offset: 2px; }
|
||||
.doc-title { font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.doc-meta { font-size: 12px; color: var(--secondary-text-color, #888); }
|
||||
.doc-row-actions { display: flex; gap: 4px; flex: none; }
|
||||
.icon-btn {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 34px; height: 34px; border-radius: 8px; cursor: pointer;
|
||||
background: transparent; border: none; color: var(--primary-text-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
.icon-btn:hover { background: var(--secondary-background-color, rgba(0,0,0,0.06)); }
|
||||
.icon-btn.danger { color: var(--error-color, #f44336); }
|
||||
.icon-btn[disabled] { opacity: 0.4; pointer-events: none; }
|
||||
.icon-btn ha-icon { --mdc-icon-size: 20px; }
|
||||
`;
|
||||
}
|
||||
|
||||
if (!customElements.get("maintenance-documents-section")) {
|
||||
customElements.define("maintenance-documents-section", MaintenanceDocumentsSection);
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
/** Dialog for creating/editing a maintenance group. */
|
||||
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
|
||||
import { t } from "../styles";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import "./ms-textfield";
|
||||
import type {
|
||||
GroupTaskRef,
|
||||
HomeAssistant,
|
||||
MaintenanceGroup,
|
||||
MaintenanceObjectResponse,
|
||||
} from "../types";
|
||||
|
||||
export class MaintenanceGroupDialog extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@property({ attribute: false }) public objects: MaintenanceObjectResponse[] = [];
|
||||
|
||||
@state() private _open = false;
|
||||
@state() private _loading = false;
|
||||
@state() private _error = "";
|
||||
@state() private _groupId: string | null = null; // null = create
|
||||
@state() private _name = "";
|
||||
@state() private _description = "";
|
||||
@state() private _selected: Set<string> = new Set(); // "entry_id:task_id"
|
||||
|
||||
private get _lang(): string {
|
||||
return this.hass?.language ?? navigator.language.split("-")[0] ?? "en";
|
||||
}
|
||||
|
||||
public openCreate(): void {
|
||||
this._reset();
|
||||
this._open = true;
|
||||
}
|
||||
|
||||
public openEdit(groupId: string, group: MaintenanceGroup): void {
|
||||
this._reset();
|
||||
this._groupId = groupId;
|
||||
this._name = group.name;
|
||||
this._description = group.description || "";
|
||||
this._selected = new Set(group.task_refs.map((r) => `${r.entry_id}:${r.task_id}`));
|
||||
this._open = true;
|
||||
}
|
||||
|
||||
private _reset(): void {
|
||||
this._groupId = null;
|
||||
this._name = "";
|
||||
this._description = "";
|
||||
this._selected = new Set();
|
||||
this._error = "";
|
||||
}
|
||||
|
||||
private _close(): void {
|
||||
this._open = false;
|
||||
}
|
||||
|
||||
private _toggleTask = (entryId: string, taskId: string): void => {
|
||||
const key = `${entryId}:${taskId}`;
|
||||
const next = new Set(this._selected);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
this._selected = next;
|
||||
};
|
||||
|
||||
private _buildTaskRefs(): GroupTaskRef[] {
|
||||
return [...this._selected].map((k) => {
|
||||
const [entry_id, task_id] = k.split(":", 2);
|
||||
return { entry_id, task_id };
|
||||
});
|
||||
}
|
||||
|
||||
private _save = async (): Promise<void> => {
|
||||
const name = this._name.trim();
|
||||
if (!name) {
|
||||
this._error = t("group_name_required", this._lang);
|
||||
return;
|
||||
}
|
||||
this._loading = true;
|
||||
this._error = "";
|
||||
try {
|
||||
const task_refs = this._buildTaskRefs();
|
||||
if (this._groupId) {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/group/update",
|
||||
group_id: this._groupId,
|
||||
name,
|
||||
description: this._description,
|
||||
task_refs,
|
||||
});
|
||||
} else {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/group/create",
|
||||
name,
|
||||
description: this._description,
|
||||
task_refs,
|
||||
});
|
||||
}
|
||||
this._open = false;
|
||||
this.dispatchEvent(new CustomEvent("group-saved"));
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang, t("save_error", this._lang));
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
if (!this._open) return html``;
|
||||
const L = this._lang;
|
||||
const title = this._groupId ? t("edit_group", L) : t("new_group", L);
|
||||
|
||||
return html`
|
||||
<ha-dialog open @closed=${this._close} heading="${title}">
|
||||
<div class="content">
|
||||
${this._error ? html`<div class="error">${this._error}</div>` : nothing}
|
||||
<ms-textfield
|
||||
label="${t("name", L)}"
|
||||
required
|
||||
.value=${this._name}
|
||||
@input=${(e: Event) => (this._name = (e.target as HTMLInputElement).value)}
|
||||
></ms-textfield>
|
||||
<ms-textfield
|
||||
label="${t("description_optional", L)}"
|
||||
.value=${this._description}
|
||||
@input=${(e: Event) => (this._description = (e.target as HTMLInputElement).value)}
|
||||
></ms-textfield>
|
||||
|
||||
<div class="section-title">${t("group_select_tasks", L)}</div>
|
||||
${this.objects.length === 0
|
||||
? html`<div class="hint">${t("no_objects", L)}</div>`
|
||||
: html`
|
||||
<div class="objects">
|
||||
${[...this.objects]
|
||||
.sort((a, b) => a.object.name.localeCompare(b.object.name))
|
||||
.map((obj) => html`
|
||||
<div class="object-block">
|
||||
<div class="object-name">${obj.object.name}</div>
|
||||
${obj.tasks.length === 0
|
||||
? html`<div class="hint small">${t("no_tasks_short", L)}</div>`
|
||||
: [...obj.tasks]
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((task) => {
|
||||
const key = `${obj.entry_id}:${task.id}`;
|
||||
const checked = this._selected.has(key);
|
||||
return html`
|
||||
<label class="task-row">
|
||||
<input type="checkbox"
|
||||
.checked=${checked}
|
||||
@change=${() => this._toggleTask(obj.entry_id, task.id)} />
|
||||
<span>${task.name}</span>
|
||||
</label>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
`}
|
||||
<div class="selected-count">
|
||||
${t("selected", L)}: ${this._selected.size}
|
||||
</div>
|
||||
</div>
|
||||
<div class="dialog-actions">
|
||||
<ha-button appearance="plain" @click=${this._close}>
|
||||
${t("cancel", L)}
|
||||
</ha-button>
|
||||
<ha-button @click=${this._save} .disabled=${this._loading || !this._name.trim()}>
|
||||
${this._loading ? t("saving", L) : t("save", L)}
|
||||
</ha-button>
|
||||
</div>
|
||||
</ha-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-width: 360px;
|
||||
max-width: 520px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.content {
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
ha-textfield { display: block; }
|
||||
.error {
|
||||
color: var(--error-color, #f44336);
|
||||
font-size: 13px;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-top: 8px;
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
.hint {
|
||||
color: var(--secondary-text-color);
|
||||
font-size: 13px;
|
||||
}
|
||||
.hint.small { font-size: 12px; padding-left: 12px; }
|
||||
.objects { display: flex; flex-direction: column; gap: 8px; }
|
||||
.object-block {
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
}
|
||||
.object-name {
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.task-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 3px 0;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.task-row input { cursor: pointer; }
|
||||
.selected-count {
|
||||
font-size: 12px;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding-top: 16px;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
if (!customElements.get("maintenance-group-dialog")) {
|
||||
customElements.define("maintenance-group-dialog", MaintenanceGroupDialog);
|
||||
}
|
||||
+340
@@ -0,0 +1,340 @@
|
||||
/** v2.4.0 — Interactive Groups Section Card.
|
||||
*
|
||||
* Replaces the read-only markdown groups card. Lets the admin add / rename /
|
||||
* delete groups inline. Task assignment lives in the panel (it needs the
|
||||
* full task picker), so each group has a "Manage tasks" link that deep-links
|
||||
* there.
|
||||
*/
|
||||
|
||||
import { LitElement, html, css, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { t, ensureLocale } from "../styles";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { sectionCardSharedStyles } from "./section-card-shared-styles";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
interface GroupEntry {
|
||||
name?: string;
|
||||
description?: string;
|
||||
task_refs?: { entry_id: string; task_id: string }[];
|
||||
}
|
||||
|
||||
interface GroupsResp {
|
||||
groups?: Record<string, GroupEntry>;
|
||||
}
|
||||
|
||||
interface CardConfig { type: string; title?: string; }
|
||||
|
||||
export class MaintenanceGroupsSectionCard extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@state() private _config: CardConfig = { type: "" };
|
||||
@state() private _groups: Record<string, GroupEntry> = {};
|
||||
@state() private _loaded = false;
|
||||
@state() private _busy = false;
|
||||
@state() private _error = "";
|
||||
@state() private _newName = "";
|
||||
@state() private _editingId: string | null = null;
|
||||
@state() private _editingName = "";
|
||||
|
||||
private _hasInitiallyLoaded = false;
|
||||
|
||||
setConfig(config: CardConfig): void {
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
getCardSize(): number {
|
||||
return 2;
|
||||
}
|
||||
|
||||
private get _lang(): string {
|
||||
return this.hass?.language || "en";
|
||||
}
|
||||
|
||||
private get _isAdmin(): boolean {
|
||||
return (this.hass?.user?.is_admin ?? true) as boolean;
|
||||
}
|
||||
|
||||
updated(changedProps: Map<string, unknown>): void {
|
||||
super.updated(changedProps);
|
||||
if (changedProps.has("hass") && this.hass && !this._hasInitiallyLoaded) {
|
||||
this._hasInitiallyLoaded = true;
|
||||
void this._load();
|
||||
void ensureLocale(this._lang).then(() => this.requestUpdate());
|
||||
}
|
||||
}
|
||||
|
||||
private async _load(): Promise<void> {
|
||||
try {
|
||||
const r = await this.hass.connection.sendMessagePromise<GroupsResp>({
|
||||
type: "maintenance_supporter/groups",
|
||||
});
|
||||
this._groups = r.groups || {};
|
||||
this._loaded = true;
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
}
|
||||
}
|
||||
|
||||
private async _addGroup(): Promise<void> {
|
||||
if (!this._isAdmin) return;
|
||||
const name = this._newName.trim();
|
||||
if (!name) return;
|
||||
this._busy = true;
|
||||
this._error = "";
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/group/create",
|
||||
name,
|
||||
});
|
||||
this._newName = "";
|
||||
await this._load();
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _startEdit(id: string): void {
|
||||
this._editingId = id;
|
||||
this._editingName = this._groups[id]?.name || "";
|
||||
}
|
||||
|
||||
private async _saveEdit(): Promise<void> {
|
||||
if (!this._isAdmin || !this._editingId) return;
|
||||
const name = this._editingName.trim();
|
||||
if (!name) return;
|
||||
this._busy = true;
|
||||
this._error = "";
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/group/update",
|
||||
group_id: this._editingId,
|
||||
name,
|
||||
});
|
||||
this._editingId = null;
|
||||
this._editingName = "";
|
||||
await this._load();
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async _deleteGroup(id: string, name: string): Promise<void> {
|
||||
if (!this._isAdmin) return;
|
||||
const confirmText = (t("group_delete_confirm", this._lang)
|
||||
|| "Delete group \"{name}\"?").replace("{name}", name);
|
||||
if (!window.confirm(confirmText)) return;
|
||||
this._busy = true;
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/group/delete",
|
||||
group_id: id,
|
||||
});
|
||||
await this._load();
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _onDeepLink(): void {
|
||||
const path = "/maintenance-supporter?ms_action=open_groups";
|
||||
history.pushState(null, "", path);
|
||||
window.dispatchEvent(new CustomEvent("location-changed"));
|
||||
}
|
||||
|
||||
private _onKeyDown(e: KeyboardEvent, action: () => void): void {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
action();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
this._editingId = null;
|
||||
this._editingName = "";
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const L = this._lang;
|
||||
if (!this._loaded) {
|
||||
return html`<ha-card><div class="loading">${t("loading", L) || "Loading…"}</div></ha-card>`;
|
||||
}
|
||||
const ids = Object.keys(this._groups);
|
||||
|
||||
return html`
|
||||
<ha-card>
|
||||
<div class="card-content">
|
||||
<div class="header">
|
||||
<div class="title">
|
||||
<span class="emoji">🏷️</span>
|
||||
<span>${this._config.title || (t("groups", L) || "Groups")}</span>
|
||||
<span class="count">${ids.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${this._error ? html`<div class="error">${this._error}</div>` : nothing}
|
||||
|
||||
${ids.length === 0
|
||||
? html`<div class="empty">${t("groups_empty", L) || "No groups yet."}</div>`
|
||||
: html`
|
||||
<div class="group-list">
|
||||
${ids.map((id) => {
|
||||
const g = this._groups[id];
|
||||
const taskCount = g.task_refs?.length ?? 0;
|
||||
const isEditing = this._editingId === id;
|
||||
return html`
|
||||
<div class="group-row">
|
||||
${isEditing
|
||||
? html`
|
||||
<input class="edit-input" type="text"
|
||||
.value=${this._editingName}
|
||||
?disabled=${this._busy}
|
||||
@input=${(e: Event) => {
|
||||
this._editingName = (e.target as HTMLInputElement).value;
|
||||
}}
|
||||
@keydown=${(e: KeyboardEvent) => this._onKeyDown(e, this._saveEdit.bind(this))} />
|
||||
<button class="btn small primary"
|
||||
@click=${this._saveEdit}
|
||||
?disabled=${this._busy || !this._editingName.trim()}>
|
||||
${t("save", L) || "Save"}
|
||||
</button>
|
||||
<button class="btn small"
|
||||
@click=${() => { this._editingId = null; }}>
|
||||
${t("cancel", L) || "Cancel"}
|
||||
</button>
|
||||
`
|
||||
: html`
|
||||
<span class="group-name">${g.name || "Unnamed"}</span>
|
||||
<span class="task-count">${taskCount}</span>
|
||||
${this._isAdmin
|
||||
? html`
|
||||
<button class="icon-btn"
|
||||
title="${t("edit", L) || "Edit"}"
|
||||
@click=${() => this._startEdit(id)}
|
||||
?disabled=${this._busy}>
|
||||
<ha-icon icon="mdi:pencil"></ha-icon>
|
||||
</button>
|
||||
<button class="icon-btn danger"
|
||||
title="${t("delete", L) || "Delete"}"
|
||||
@click=${() => this._deleteGroup(id, g.name || "Unnamed")}
|
||||
?disabled=${this._busy}>
|
||||
<ha-icon icon="mdi:delete"></ha-icon>
|
||||
</button>
|
||||
`
|
||||
: nothing}
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`}
|
||||
|
||||
${this._isAdmin
|
||||
? html`
|
||||
<div class="add-row">
|
||||
<input type="text"
|
||||
placeholder="${t("group_new_placeholder", L) || "Add group…"}"
|
||||
.value=${this._newName}
|
||||
?disabled=${this._busy}
|
||||
@input=${(e: Event) => {
|
||||
this._newName = (e.target as HTMLInputElement).value;
|
||||
}}
|
||||
@keydown=${(e: KeyboardEvent) => this._onKeyDown(e, this._addGroup.bind(this))} />
|
||||
<button class="btn primary"
|
||||
@click=${this._addGroup}
|
||||
?disabled=${this._busy || !this._newName.trim()}>
|
||||
<ha-icon icon="mdi:plus"></ha-icon>
|
||||
${t("add", L) || "Add"}
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn link" @click=${this._onDeepLink}>
|
||||
${t("groups_manage_tasks", L) || "Manage task assignments…"}
|
||||
</button>
|
||||
`
|
||||
: html`
|
||||
<button class="btn link" @click=${this._onDeepLink}>
|
||||
${t("groups_open_panel", L) || "Open in panel"}
|
||||
</button>
|
||||
`}
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = [sectionCardSharedStyles, css`
|
||||
.count {
|
||||
font-size: 12px; color: var(--secondary-text-color);
|
||||
background: var(--secondary-background-color);
|
||||
padding: 2px 8px; border-radius: 999px;
|
||||
}
|
||||
.empty {
|
||||
padding: 16px; text-align: center;
|
||||
color: var(--secondary-text-color); font-style: italic;
|
||||
}
|
||||
.group-list { display: flex; flex-direction: column; gap: 4px; }
|
||||
.group-row {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 6px 8px; border-radius: 6px;
|
||||
background: var(--secondary-background-color, rgba(255,255,255,0.03));
|
||||
}
|
||||
.group-name { flex: 1; font-size: 14px; }
|
||||
.task-count {
|
||||
font-size: 11px; color: var(--secondary-text-color);
|
||||
background: var(--card-background-color, rgba(0,0,0,0.2));
|
||||
padding: 1px 8px; border-radius: 999px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.edit-input {
|
||||
flex: 1; padding: 4px 8px; font-size: 14px;
|
||||
background: var(--card-background-color, #1c1c1c);
|
||||
color: var(--primary-text-color);
|
||||
border: 1px solid var(--primary-color); border-radius: 4px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.icon-btn {
|
||||
background: transparent; border: none; cursor: pointer;
|
||||
color: var(--secondary-text-color); padding: 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.icon-btn:hover {
|
||||
background: var(--state-icon-color, rgba(255,255,255,0.06));
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
.icon-btn.danger:hover { color: var(--error-color); }
|
||||
.icon-btn ha-icon { --mdc-icon-size: 18px; }
|
||||
.add-row {
|
||||
display: flex; gap: 6px;
|
||||
padding-top: 8px; border-top: 1px solid var(--divider-color);
|
||||
}
|
||||
.add-row input {
|
||||
flex: 1; padding: 6px 8px; font-size: 13px;
|
||||
background: var(--secondary-background-color, #2c2c2c);
|
||||
color: var(--primary-text-color);
|
||||
border: 1px solid var(--divider-color); border-radius: 6px;
|
||||
font-family: inherit;
|
||||
}
|
||||
/* Card-specific overrides on the shared .btn */
|
||||
.btn.small { padding: 4px 8px; font-size: 12px; }
|
||||
.btn ha-icon { --mdc-icon-size: 16px; }
|
||||
`];
|
||||
}
|
||||
|
||||
if (!customElements.get("maintenance-groups-section-card")) {
|
||||
customElements.define(
|
||||
"maintenance-groups-section-card",
|
||||
MaintenanceGroupsSectionCard,
|
||||
);
|
||||
}
|
||||
|
||||
(window as { customCards?: unknown[] }).customCards =
|
||||
(window as { customCards?: unknown[] }).customCards || [];
|
||||
((window as { customCards?: unknown[] }).customCards!).push({
|
||||
type: "maintenance-groups-section-card",
|
||||
name: "Maintenance Supporter — Groups",
|
||||
description: "Inline group CRUD",
|
||||
preview: false,
|
||||
});
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
/** Dialog for editing an existing history entry (timestamp / notes / cost /
|
||||
* duration / completed_by). Backed by maintenance_supporter/task/history/update.
|
||||
*
|
||||
* Opened from:
|
||||
* - Task detail page → history tab → Edit button per entry
|
||||
* - Calendar past-window event click (via ll-custom dispatch from
|
||||
* maintenance-supporter-calendar-card)
|
||||
*/
|
||||
|
||||
import { LitElement, html, css, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { t } from "../styles";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
|
||||
export interface HistoryEntryDraft {
|
||||
entry_id: string;
|
||||
task_id: string;
|
||||
original_timestamp: string; // identifies the entry on save
|
||||
type: string; // for display only — read-only
|
||||
timestamp: string;
|
||||
notes: string | null;
|
||||
cost: number | null;
|
||||
duration: number | null;
|
||||
completed_by: string | null;
|
||||
}
|
||||
|
||||
export class MaintenanceHistoryEditDialog extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _open = false;
|
||||
@state() private _saving = false;
|
||||
@state() private _error = "";
|
||||
@state() private _draft: HistoryEntryDraft | null = null;
|
||||
|
||||
// Original snapshot so we can detect "no change" and skip the WS call
|
||||
private _originalSnapshot: HistoryEntryDraft | null = null;
|
||||
|
||||
private get _lang(): string {
|
||||
return this.hass?.language || "en";
|
||||
}
|
||||
|
||||
/** Open the dialog with the given history-entry data. The caller must
|
||||
* pass `original_timestamp` (the entry's current timestamp before edit)
|
||||
* so the backend can find the entry. */
|
||||
public openEdit(draft: HistoryEntryDraft): void {
|
||||
this._draft = { ...draft };
|
||||
this._originalSnapshot = { ...draft };
|
||||
this._error = "";
|
||||
this._open = true;
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
this._open = false;
|
||||
this._error = "";
|
||||
this._draft = null;
|
||||
this._originalSnapshot = null;
|
||||
}
|
||||
|
||||
private _set<K extends keyof HistoryEntryDraft>(
|
||||
key: K, value: HistoryEntryDraft[K],
|
||||
): void {
|
||||
if (!this._draft) return;
|
||||
this._draft = { ...this._draft, [key]: value };
|
||||
}
|
||||
|
||||
private async _save(): Promise<void> {
|
||||
if (!this._draft || !this._originalSnapshot) return;
|
||||
this._saving = true;
|
||||
this._error = "";
|
||||
try {
|
||||
const patch: Record<string, unknown> = {
|
||||
type: "maintenance_supporter/task/history/update",
|
||||
entry_id: this._draft.entry_id,
|
||||
task_id: this._draft.task_id,
|
||||
original_timestamp: this._originalSnapshot.original_timestamp,
|
||||
};
|
||||
// Only send fields that actually changed — keeps the patch minimal
|
||||
// and the WS schema happy (it treats missing as "no change").
|
||||
if (this._draft.timestamp !== this._originalSnapshot.timestamp) {
|
||||
patch.timestamp = this._draft.timestamp;
|
||||
}
|
||||
if (this._draft.notes !== this._originalSnapshot.notes) {
|
||||
patch.notes = this._draft.notes;
|
||||
}
|
||||
if (this._draft.cost !== this._originalSnapshot.cost) {
|
||||
patch.cost = this._draft.cost;
|
||||
}
|
||||
if (this._draft.duration !== this._originalSnapshot.duration) {
|
||||
patch.duration = this._draft.duration;
|
||||
}
|
||||
if (this._draft.completed_by !== this._originalSnapshot.completed_by) {
|
||||
patch.completed_by = this._draft.completed_by;
|
||||
}
|
||||
// Nothing changed → close without WS call
|
||||
const changedKeys = Object.keys(patch).filter(
|
||||
(k) => !["type", "entry_id", "task_id", "original_timestamp"].includes(k),
|
||||
);
|
||||
if (changedKeys.length === 0) {
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
await this.hass.connection.sendMessagePromise(patch);
|
||||
// Notify upstream so they can refresh
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("history-entry-saved", {
|
||||
detail: {
|
||||
entry_id: this._draft.entry_id,
|
||||
task_id: this._draft.task_id,
|
||||
new_timestamp: this._draft.timestamp,
|
||||
},
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
this.close();
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this._open || !this._draft) return nothing;
|
||||
const L = this._lang;
|
||||
const d = this._draft;
|
||||
return html`
|
||||
<div class="backdrop" @click=${this.close}></div>
|
||||
<div class="dialog" role="dialog" aria-modal="true">
|
||||
<h2>${t("history_edit_title", L) || "Edit history entry"}</h2>
|
||||
<div class="entry-type">
|
||||
<ha-icon icon="mdi:tag-outline"></ha-icon>
|
||||
<span>${t(d.type, L) || d.type}</span>
|
||||
</div>
|
||||
<label>
|
||||
<span>${t("history_edit_timestamp", L) || "Timestamp"}</span>
|
||||
<input type="datetime-local"
|
||||
.value=${d.timestamp.length >= 16 ? d.timestamp.slice(0, 16) : d.timestamp}
|
||||
@change=${(e: Event) => {
|
||||
const v = (e.target as HTMLInputElement).value;
|
||||
// Re-add seconds if input drops them
|
||||
this._set("timestamp", v.length === 16 ? `${v}:00` : v);
|
||||
}} />
|
||||
</label>
|
||||
<label>
|
||||
<span>${t("notes_label", L)}</span>
|
||||
<textarea
|
||||
rows="3"
|
||||
@input=${(e: Event) => {
|
||||
const v = (e.target as HTMLTextAreaElement).value;
|
||||
this._set("notes", v ? v : null);
|
||||
}}
|
||||
.value=${d.notes ?? ""}></textarea>
|
||||
</label>
|
||||
<div class="row">
|
||||
<label>
|
||||
<span>${t("cost", L) || "Cost"}</span>
|
||||
<input type="number" min="0" step="0.01"
|
||||
.value=${d.cost != null ? String(d.cost) : ""}
|
||||
@input=${(e: Event) => {
|
||||
const v = (e.target as HTMLInputElement).value;
|
||||
this._set("cost", v ? Number(v) : null);
|
||||
}} />
|
||||
</label>
|
||||
<label>
|
||||
<span>${t("duration", L) || "Duration (min)"}</span>
|
||||
<input type="number" min="0"
|
||||
.value=${d.duration != null ? String(d.duration) : ""}
|
||||
@input=${(e: Event) => {
|
||||
const v = (e.target as HTMLInputElement).value;
|
||||
this._set("duration", v ? Number(v) : null);
|
||||
}} />
|
||||
</label>
|
||||
</div>
|
||||
${this._error ? html`<div class="error">${this._error}</div>` : nothing}
|
||||
<div class="actions">
|
||||
<button class="cancel" @click=${this.close} ?disabled=${this._saving}>
|
||||
${t("cancel", L) || "Cancel"}
|
||||
</button>
|
||||
<button class="save" @click=${this._save} ?disabled=${this._saving}>
|
||||
${this._saving ? (t("saving", L) || "Saving…") : (t("save", L) || "Save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host { display: contents; }
|
||||
.backdrop {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
z-index: 100;
|
||||
}
|
||||
.dialog {
|
||||
position: fixed; left: 50%; top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 95vw; max-width: 480px;
|
||||
background: var(--card-background-color, var(--ha-card-background, #1c1c1c));
|
||||
color: var(--primary-text-color);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.4);
|
||||
padding: 20px;
|
||||
display: flex; flex-direction: column; gap: 12px;
|
||||
z-index: 101;
|
||||
max-height: 90vh; overflow: auto;
|
||||
}
|
||||
h2 { margin: 0; font-size: 18px; }
|
||||
.entry-type {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
color: var(--secondary-text-color); font-size: 13px;
|
||||
}
|
||||
label { display: flex; flex-direction: column; gap: 4px; font-size: 13px; }
|
||||
label span { color: var(--secondary-text-color); }
|
||||
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
input, textarea {
|
||||
padding: 8px; font-size: 14px;
|
||||
background: var(--secondary-background-color, #2c2c2c);
|
||||
color: var(--primary-text-color);
|
||||
border: 1px solid var(--divider-color, #444);
|
||||
border-radius: 6px;
|
||||
width: 100%; box-sizing: border-box;
|
||||
font-family: inherit;
|
||||
}
|
||||
.actions {
|
||||
display: flex; gap: 8px; justify-content: flex-end;
|
||||
margin-top: 8px;
|
||||
}
|
||||
button {
|
||||
padding: 8px 16px; font-size: 14px;
|
||||
border-radius: 6px; cursor: pointer;
|
||||
border: none; font-weight: 500;
|
||||
}
|
||||
button.cancel {
|
||||
background: transparent;
|
||||
color: var(--primary-text-color);
|
||||
border: 1px solid var(--divider-color);
|
||||
}
|
||||
button.save {
|
||||
background: var(--primary-color);
|
||||
color: var(--text-primary-color, white);
|
||||
}
|
||||
button[disabled] { opacity: 0.5; cursor: wait; }
|
||||
.error {
|
||||
color: var(--error-color, #d32f2f);
|
||||
font-size: 13px; padding: 8px;
|
||||
background: rgba(211,47,47,0.1);
|
||||
border-radius: 6px;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
if (!customElements.get("maintenance-history-edit-dialog")) {
|
||||
customElements.define(
|
||||
"maintenance-history-edit-dialog",
|
||||
MaintenanceHistoryEditDialog,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/** Thumbnail for a completion photo in the task history timeline.
|
||||
*
|
||||
* The photo is a DocumentStore file (tagged "photo"); its bytes are served,
|
||||
* auth-gated, at /api/maintenance_supporter/document/<doc_id>. An <img> can't
|
||||
* send the auth header, so we mint a short-lived signed path via auth/sign_path
|
||||
* (the same Companion-safe pattern documents-section uses) and point the <img>
|
||||
* at that. Clicking opens the full image in a new tab.
|
||||
*/
|
||||
|
||||
import { LitElement, html, css, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
export class MaintenanceHistoryPhoto extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@property() public docId = "";
|
||||
@state() private _url = "";
|
||||
@state() private _failed = false;
|
||||
private _signedFor = "";
|
||||
|
||||
updated(): void {
|
||||
if (this.hass && this.docId && this._signedFor !== this.docId) {
|
||||
this._signedFor = this.docId;
|
||||
this._url = "";
|
||||
this._failed = false;
|
||||
void this._sign();
|
||||
}
|
||||
}
|
||||
|
||||
private async _sign(): Promise<void> {
|
||||
try {
|
||||
const signed = await this.hass.connection.sendMessagePromise<{ path: string }>({
|
||||
type: "auth/sign_path",
|
||||
path: `/api/maintenance_supporter/document/${this.docId}`,
|
||||
expires: 300,
|
||||
});
|
||||
this._url = signed.path;
|
||||
} catch {
|
||||
this._failed = true;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this._failed || !this.docId) return nothing;
|
||||
if (!this._url) return html`<div class="ph"></div>`;
|
||||
return html`
|
||||
<a href=${this._url} target="_blank" rel="noopener" class="wrap">
|
||||
<img src=${this._url} alt="" loading="lazy"
|
||||
@error=${() => (this._failed = true)} />
|
||||
</a>`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
.wrap { display: inline-block; margin-top: 4px; }
|
||||
img {
|
||||
max-width: 96px;
|
||||
max-height: 96px;
|
||||
border-radius: 6px;
|
||||
display: block;
|
||||
border: 1px solid var(--divider-color);
|
||||
}
|
||||
.ph {
|
||||
width: 96px;
|
||||
height: 64px;
|
||||
border-radius: 6px;
|
||||
background: var(--secondary-background-color, rgba(0,0,0,0.06));
|
||||
margin-top: 4px;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
if (!customElements.get("maintenance-history-photo")) {
|
||||
customElements.define("maintenance-history-photo", MaintenanceHistoryPhoto);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/** ms-textfield — drop-in for `<ha-textfield>` that always renders.
|
||||
*
|
||||
* Background: HA's `<ha-textfield>` is lazy-loaded by HA's frontend on first
|
||||
* use. In contexts where HA hasn't yet imported it (custom panels mounted
|
||||
* via panel_custom; Lovelace dialogs mounted via dialog-mount onto
|
||||
* document.body), `customElements.get("ha-textfield")` returns undefined,
|
||||
* the element renders as HTMLUnknownElement with `offsetHeight: 0`, and
|
||||
* the user sees an apparently empty form with only the label visible.
|
||||
*
|
||||
* Reported manifestations:
|
||||
* - Issue #50: complete-dialog notes/cost/duration invisible
|
||||
* - Issue #50 follow-up: task-dialog target-entity invisible
|
||||
* - Issue #46 follow-up: object-dialog name/manufacturer/model/serial/url/
|
||||
* notes invisible (only ha-area-picker rendered, hence user could
|
||||
* "only change the area")
|
||||
*
|
||||
* This wrapper uses a native `<input>` element styled to match HA's
|
||||
* appearance. Same API surface as ha-textfield: `label`, `value`, `type`,
|
||||
* `placeholder`, `required`, `step`, `min`, `max`, `pattern`. Fires a
|
||||
* standard `input` event with `event.target.value` so existing handlers
|
||||
* work unchanged.
|
||||
*
|
||||
* The CSS uses HA's CSS custom properties so the input visually matches
|
||||
* the surrounding HA UI in both light and dark themes.
|
||||
*/
|
||||
|
||||
import { LitElement, html, css, nothing } from "lit";
|
||||
import { property } from "lit/decorators.js";
|
||||
|
||||
export class MsTextfield extends LitElement {
|
||||
@property() public label = "";
|
||||
@property() public value = "";
|
||||
@property() public placeholder = "";
|
||||
@property() public type: "text" | "number" | "url" | "email" | "password" | "date" | "time" | "datetime-local" = "text";
|
||||
@property({ type: Boolean }) public required = false;
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
@property() public step?: string;
|
||||
@property() public min?: string;
|
||||
@property() public max?: string;
|
||||
@property() public pattern?: string;
|
||||
@property() public helper?: string;
|
||||
|
||||
/** Forwards the native input's value into our `value` property and
|
||||
* re-fires as a bubbling `input` event so consumers reading
|
||||
* `(e.target as HTMLInputElement).value` still work — that's the
|
||||
* pattern used everywhere ha-textfield was. */
|
||||
private _onInput(e: Event): void {
|
||||
const v = (e.target as HTMLInputElement).value;
|
||||
this.value = v;
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("input", { bubbles: true, composed: true, detail: { value: v } }),
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<label class="field">
|
||||
${this.label ? html`<span class="label">${this.label}${this.required ? html`<span class="req">*</span>` : nothing}</span>` : nothing}
|
||||
<input
|
||||
.value=${this.value ?? ""}
|
||||
.type=${this.type}
|
||||
?required=${this.required}
|
||||
?disabled=${this.disabled}
|
||||
placeholder=${this.placeholder}
|
||||
step=${this.step ?? nothing}
|
||||
min=${this.min ?? nothing}
|
||||
max=${this.max ?? nothing}
|
||||
pattern=${this.pattern ?? nothing}
|
||||
@input=${this._onInput}
|
||||
@change=${this._onInput}
|
||||
/>
|
||||
${this.helper ? html`<span class="helper">${this.helper}</span>` : nothing}
|
||||
</label>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host { display: block; }
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.label {
|
||||
font-size: 12px;
|
||||
color: var(--secondary-text-color, #888);
|
||||
font-weight: 500;
|
||||
}
|
||||
.req { color: var(--error-color, #f44336); margin-left: 2px; }
|
||||
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, rgba(255,255,255,0.12));
|
||||
border-radius: 6px;
|
||||
font-family: inherit;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
}
|
||||
input:focus {
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
input:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.helper {
|
||||
font-size: 11px;
|
||||
color: var(--secondary-text-color);
|
||||
font-style: italic;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
if (!customElements.get("ms-textfield")) {
|
||||
customElements.define("ms-textfield", MsTextfield);
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
/** Dialog for creating/editing a maintenance object. */
|
||||
|
||||
import { LitElement, html, css, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import type { HomeAssistant, MaintenanceObject, MaintenanceObjectResponse } from "../types";
|
||||
import { t } from "../styles";
|
||||
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import "./ms-textfield";
|
||||
|
||||
export class MaintenanceObjectDialog extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
/** All objects — choices for the parent-object picker (2.19). */
|
||||
@property({ attribute: false }) public objects: MaintenanceObjectResponse[] = [];
|
||||
@state() private _open = false;
|
||||
@state() private _loading = false;
|
||||
@state() private _error = "";
|
||||
@state() private _name = "";
|
||||
@state() private _manufacturer = "";
|
||||
@state() private _model = "";
|
||||
@state() private _serialNumber = "";
|
||||
@state() private _areaId = "";
|
||||
@state() private _installationDate = "";
|
||||
// (#67): per-object warranty expiry date
|
||||
@state() private _warrantyExpiry = "";
|
||||
// v1.4.0 (#43): per-object link to PDF manual / vendor page
|
||||
@state() private _documentationUrl = "";
|
||||
// v1.4.10 (#46): free-form notes (multiline)
|
||||
@state() private _notes = "";
|
||||
// 2.19: attach to an existing HA device / nest under another object
|
||||
@state() private _haDeviceId = "";
|
||||
@state() private _parentEntryId = "";
|
||||
@state() private _entryId: string | null = null; // null = create, string = update
|
||||
|
||||
private get _lang(): string {
|
||||
return this.hass?.language ?? navigator.language.split("-")[0] ?? "en";
|
||||
}
|
||||
|
||||
public openCreate(): void {
|
||||
this._entryId = null;
|
||||
this._name = "";
|
||||
this._manufacturer = "";
|
||||
this._model = "";
|
||||
this._serialNumber = "";
|
||||
this._areaId = "";
|
||||
this._installationDate = "";
|
||||
this._warrantyExpiry = "";
|
||||
this._documentationUrl = "";
|
||||
this._notes = "";
|
||||
this._haDeviceId = "";
|
||||
this._parentEntryId = "";
|
||||
this._error = "";
|
||||
this._open = true;
|
||||
}
|
||||
|
||||
public openEdit(entryId: string, obj: MaintenanceObject): void {
|
||||
this._entryId = entryId;
|
||||
this._name = obj.name || "";
|
||||
this._manufacturer = obj.manufacturer || "";
|
||||
this._model = obj.model || "";
|
||||
this._serialNumber = obj.serial_number || "";
|
||||
this._areaId = obj.area_id || "";
|
||||
this._installationDate = obj.installation_date || "";
|
||||
this._warrantyExpiry = obj.warranty_expiry || "";
|
||||
this._documentationUrl = obj.documentation_url || "";
|
||||
this._notes = obj.notes || "";
|
||||
this._haDeviceId = obj.ha_device_id || "";
|
||||
this._parentEntryId = obj.parent_entry_id || "";
|
||||
this._error = "";
|
||||
this._open = true;
|
||||
}
|
||||
|
||||
private async _save(): Promise<void> {
|
||||
if (this._loading) return; // synchronous re-entry guard (double-click)
|
||||
if (!this._name.trim()) return;
|
||||
this._loading = true;
|
||||
this._error = "";
|
||||
try {
|
||||
if (this._entryId) {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/object/update",
|
||||
entry_id: this._entryId,
|
||||
name: this._name,
|
||||
manufacturer: this._manufacturer || null,
|
||||
model: this._model || null,
|
||||
serial_number: this._serialNumber || null,
|
||||
area_id: this._areaId || null,
|
||||
installation_date: this._installationDate || null,
|
||||
warranty_expiry: this._warrantyExpiry || null,
|
||||
documentation_url: this._documentationUrl.trim() || null,
|
||||
notes: this._notes.trim() || null,
|
||||
ha_device_id: this._haDeviceId || null,
|
||||
parent_entry_id: this._parentEntryId || null,
|
||||
});
|
||||
} else {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/object/create",
|
||||
name: this._name,
|
||||
manufacturer: this._manufacturer || null,
|
||||
model: this._model || null,
|
||||
serial_number: this._serialNumber || null,
|
||||
area_id: this._areaId || null,
|
||||
installation_date: this._installationDate || null,
|
||||
warranty_expiry: this._warrantyExpiry || null,
|
||||
documentation_url: this._documentationUrl.trim() || null,
|
||||
notes: this._notes.trim() || null,
|
||||
ha_device_id: this._haDeviceId || null,
|
||||
parent_entry_id: this._parentEntryId || null,
|
||||
});
|
||||
}
|
||||
this._open = false;
|
||||
this.dispatchEvent(new CustomEvent("object-saved"));
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang, t("save_error", this._lang));
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _parentChoices(): MaintenanceObjectResponse[] {
|
||||
return (this.objects || []).filter((o) => o.entry_id !== this._entryId);
|
||||
}
|
||||
|
||||
private _close(): void {
|
||||
this._open = false;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this._open) return html``;
|
||||
const L = this._lang;
|
||||
const title = this._entryId ? t("edit_object", L) : t("new_object", L);
|
||||
return html`
|
||||
<ha-dialog open @closed=${this._close}>
|
||||
<div class="dialog-title">${title}</div>
|
||||
<div class="content">
|
||||
${this._error ? html`<div class="error">${this._error}</div>` : nothing}
|
||||
<ms-textfield
|
||||
label="${t("name", L)}"
|
||||
required
|
||||
.value=${this._name}
|
||||
@input=${(e: Event) => (this._name = (e.target as HTMLInputElement).value)}
|
||||
></ms-textfield>
|
||||
<ms-textfield
|
||||
label="${t("manufacturer_optional", L)}"
|
||||
.value=${this._manufacturer}
|
||||
@input=${(e: Event) => (this._manufacturer = (e.target as HTMLInputElement).value)}
|
||||
></ms-textfield>
|
||||
<ms-textfield
|
||||
label="${t("model_optional", L)}"
|
||||
.value=${this._model}
|
||||
@input=${(e: Event) => (this._model = (e.target as HTMLInputElement).value)}
|
||||
></ms-textfield>
|
||||
<ms-textfield
|
||||
label="${t("serial_number_optional", L)}"
|
||||
.value=${this._serialNumber}
|
||||
@input=${(e: Event) => (this._serialNumber = (e.target as HTMLInputElement).value)}
|
||||
></ms-textfield>
|
||||
<ms-textfield
|
||||
label="${t("documentation_url_optional", L)}"
|
||||
type="url"
|
||||
.value=${this._documentationUrl}
|
||||
@input=${(e: Event) => (this._documentationUrl = (e.target as HTMLInputElement).value)}
|
||||
></ms-textfield>
|
||||
<ha-area-picker
|
||||
.hass=${this.hass}
|
||||
label="${t("area_id_optional", L)}"
|
||||
.value=${this._areaId}
|
||||
@value-changed=${(e: CustomEvent) =>
|
||||
(this._areaId = (e.detail.value as string) || "")}
|
||||
></ha-area-picker>
|
||||
<ms-textfield
|
||||
label="${t("installation_date_optional", L)}"
|
||||
type="date"
|
||||
.value=${this._installationDate}
|
||||
@input=${(e: Event) => (this._installationDate = (e.target as HTMLInputElement).value)}
|
||||
></ms-textfield>
|
||||
<ms-textfield
|
||||
label="${t("warranty_expiry_optional", L)}"
|
||||
type="date"
|
||||
.value=${this._warrantyExpiry}
|
||||
@input=${(e: Event) => (this._warrantyExpiry = (e.target as HTMLInputElement).value)}
|
||||
></ms-textfield>
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${{ device: this._haDeviceId || undefined }}
|
||||
.schema=${[{ name: "device", selector: { device: {} } }]}
|
||||
.computeLabel=${() => t("link_device_optional", L)}
|
||||
@value-changed=${(e: CustomEvent) =>
|
||||
(this._haDeviceId =
|
||||
((e.detail.value as { device?: string })?.device as string) || "")}
|
||||
></ha-form>
|
||||
${this._parentChoices().length
|
||||
? html`<label class="textarea-field">
|
||||
<span class="textarea-label">${t("parent_object_optional", L)}</span>
|
||||
<select
|
||||
class="parent-select"
|
||||
.value=${this._parentEntryId}
|
||||
@change=${(e: Event) =>
|
||||
(this._parentEntryId = (e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
<option value="" ?selected=${!this._parentEntryId}>
|
||||
${t("parent_none", L)}
|
||||
</option>
|
||||
${this._parentChoices().map(
|
||||
(o) => html`<option
|
||||
value=${o.entry_id}
|
||||
?selected=${this._parentEntryId === o.entry_id}
|
||||
>${o.object.name}</option>`,
|
||||
)}
|
||||
</select>
|
||||
</label>`
|
||||
: nothing}
|
||||
<label class="textarea-field">
|
||||
<span class="textarea-label">${t("object_notes_optional", L)}</span>
|
||||
<textarea
|
||||
rows="3"
|
||||
.value=${this._notes}
|
||||
@input=${(e: Event) => (this._notes = (e.target as HTMLTextAreaElement).value)}
|
||||
></textarea>
|
||||
</label>
|
||||
</div>
|
||||
<div class="dialog-actions">
|
||||
<ha-button appearance="plain" @click=${this._close}>
|
||||
${t("cancel", this._lang)}
|
||||
</ha-button>
|
||||
<ha-button
|
||||
@click=${this._save}
|
||||
.disabled=${this._loading || !this._name.trim()}
|
||||
>
|
||||
${this._loading ? t("saving", this._lang) : t("save", this._lang)}
|
||||
</ha-button>
|
||||
</div>
|
||||
</ha-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
.dialog-title {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-width: 300px;
|
||||
}
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding-top: 16px;
|
||||
}
|
||||
ms-textfield {
|
||||
display: block;
|
||||
}
|
||||
.textarea-field {
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
}
|
||||
.textarea-label {
|
||||
font-size: 12px; color: var(--secondary-text-color, #888); font-weight: 500;
|
||||
}
|
||||
.textarea-field textarea {
|
||||
padding: 8px 10px; font-size: 14px; font-family: inherit;
|
||||
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;
|
||||
resize: vertical;
|
||||
}
|
||||
.textarea-field textarea:focus {
|
||||
outline: none; border-color: var(--primary-color);
|
||||
}
|
||||
.parent-select {
|
||||
padding: 8px 10px; font-size: 14px; font-family: inherit;
|
||||
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;
|
||||
}
|
||||
.error {
|
||||
color: var(--error-color, #f44336);
|
||||
font-size: 13px;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
if (!customElements.get("maintenance-object-dialog")) {
|
||||
customElements.define("maintenance-object-dialog", MaintenanceObjectDialog);
|
||||
}
|
||||
+320
@@ -0,0 +1,320 @@
|
||||
/** v2.3.0 Phase 3 — Object Quick-Actions Dialog.
|
||||
*
|
||||
* In-place dialog for object-level actions: Edit settings / Add task /
|
||||
* Delete object, plus a read-only display of all metadata + a compact
|
||||
* task-list for the object. Mirrors what the panel's object-detail page
|
||||
* offers, accessible without panel-roundtrip.
|
||||
*
|
||||
* Mounted via dialog-mount.openObjectQuickActions(entryId).
|
||||
*/
|
||||
|
||||
import { LitElement, html, css, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { t, STATUS_COLORS } from "../styles";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import type { HomeAssistant, MaintenanceObject, MaintenanceTask } from "../types";
|
||||
|
||||
interface ObjectFull {
|
||||
entry_id: string;
|
||||
object: MaintenanceObject;
|
||||
tasks: MaintenanceTask[];
|
||||
}
|
||||
|
||||
export class MaintenanceObjectQuickActionsDialog extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _open = false;
|
||||
@state() private _entryId: string | null = null;
|
||||
@state() private _data: ObjectFull | null = null;
|
||||
@state() private _busy = false;
|
||||
@state() private _error = "";
|
||||
|
||||
private get _lang(): string {
|
||||
return this.hass?.language || "en";
|
||||
}
|
||||
|
||||
public async openFor(entryId: string): Promise<void> {
|
||||
this._entryId = entryId;
|
||||
this._error = "";
|
||||
this._open = true;
|
||||
await this._load();
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
this._open = false;
|
||||
this._data = null;
|
||||
this._error = "";
|
||||
}
|
||||
|
||||
private async _load(): Promise<void> {
|
||||
if (!this._entryId) return;
|
||||
try {
|
||||
const r = await this.hass.connection.sendMessagePromise<ObjectFull>({
|
||||
type: "maintenance_supporter/object",
|
||||
entry_id: this._entryId,
|
||||
});
|
||||
this._data = r;
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
}
|
||||
}
|
||||
|
||||
private _onEditObject(): void {
|
||||
if (!this._entryId || !this._data) return;
|
||||
import("../dialog-mount").then(({ openEditObjectDialog }) => {
|
||||
openEditObjectDialog(this._entryId!, this._data!.object);
|
||||
this.close();
|
||||
});
|
||||
}
|
||||
|
||||
private _onAddTask(): void {
|
||||
if (!this._entryId) return;
|
||||
import("../dialog-mount").then(({ openCreateTaskDialog }) => {
|
||||
openCreateTaskDialog();
|
||||
this.close();
|
||||
});
|
||||
}
|
||||
|
||||
private async _onDelete(): Promise<void> {
|
||||
if (!this._entryId || !this._data) return;
|
||||
const confirmText = t("delete_object_confirm", this._lang)
|
||||
|| `Delete "${this._data.object.name}" and all its tasks?`;
|
||||
if (!window.confirm(confirmText)) return;
|
||||
this._busy = true;
|
||||
this._error = "";
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/object/delete",
|
||||
entry_id: this._entryId,
|
||||
});
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("object-deleted", {
|
||||
detail: { entry_id: this._entryId },
|
||||
bubbles: true, composed: true,
|
||||
}),
|
||||
);
|
||||
this.close();
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async _onArchiveObject(): Promise<void> {
|
||||
if (!this._entryId || !this._data) return;
|
||||
const archived = !!this._data.object.archived;
|
||||
if (!archived) {
|
||||
const confirmText = t("confirm_archive_object", this._lang)
|
||||
|| "Archive this object and its tasks?";
|
||||
if (!window.confirm(confirmText)) return;
|
||||
}
|
||||
this._busy = true;
|
||||
this._error = "";
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: archived
|
||||
? "maintenance_supporter/object/unarchive"
|
||||
: "maintenance_supporter/object/archive",
|
||||
entry_id: this._entryId,
|
||||
});
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("object-changed", {
|
||||
detail: { entry_id: this._entryId },
|
||||
bubbles: true, composed: true,
|
||||
}),
|
||||
);
|
||||
this.close();
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _onTaskClick(taskId: string): void {
|
||||
if (!this._entryId) return;
|
||||
// Open the task quick-actions for this task — reuses existing dialog
|
||||
import("../dialog-mount").then(({ openTaskQuickActions }) => {
|
||||
openTaskQuickActions(this._entryId!, taskId);
|
||||
// Keep this dialog open so user lands back on object after closing task dialog
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this._open) return nothing;
|
||||
const L = this._lang;
|
||||
const data = this._data;
|
||||
const obj = data?.object;
|
||||
const tasks = data?.tasks || [];
|
||||
const isAdmin = (this.hass?.user?.is_admin ?? true) as boolean;
|
||||
|
||||
return html`
|
||||
<div class="backdrop" @click=${this.close}></div>
|
||||
<div class="dialog" role="dialog" aria-modal="true">
|
||||
${data && obj
|
||||
? html`
|
||||
<div class="header">
|
||||
<div class="title">${obj.name}</div>
|
||||
${this._renderMetaRow(obj)}
|
||||
</div>
|
||||
|
||||
${this._error ? html`<div class="error">${this._error}</div>` : nothing}
|
||||
|
||||
<div class="tasks-section">
|
||||
<div class="section-header">
|
||||
<strong>${t("tasks", L) || "Tasks"}</strong>
|
||||
<span class="count">${tasks.length}</span>
|
||||
</div>
|
||||
${tasks.length === 0
|
||||
? html`<div class="empty">${t("no_tasks", L) || "No tasks yet."}</div>`
|
||||
: html`
|
||||
<div class="task-list">
|
||||
${tasks.map((task) => html`
|
||||
<div class="task-row" @click=${() => this._onTaskClick(task.id)}>
|
||||
<span class="status-dot" style="background: ${STATUS_COLORS[task.status] || "#ccc"}"></span>
|
||||
<span class="task-name">${task.name}</span>
|
||||
<span class="task-status">${t(task.status || "ok", L)}</span>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
|
||||
${obj.notes
|
||||
? html`
|
||||
<div class="notes-section">
|
||||
<strong>${t("object_notes_label", L)}</strong>
|
||||
<div class="notes-body">${obj.notes}</div>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
|
||||
${isAdmin
|
||||
? html`
|
||||
<div class="actions">
|
||||
<button class="btn primary" @click=${this._onAddTask} ?disabled=${this._busy}>
|
||||
<ha-icon icon="mdi:plus"></ha-icon>
|
||||
${t("add_task", L) || "Add task"}
|
||||
</button>
|
||||
<button class="btn" @click=${this._onEditObject} ?disabled=${this._busy}>
|
||||
<ha-icon icon="mdi:pencil"></ha-icon>
|
||||
${t("edit", L) || "Edit"}
|
||||
</button>
|
||||
<button class="btn" @click=${this._onArchiveObject} ?disabled=${this._busy}>
|
||||
<ha-icon icon="${obj.archived ? 'mdi:archive-arrow-up-outline' : 'mdi:archive-outline'}"></ha-icon>
|
||||
${obj.archived ? (t("unarchive_object", L) || "Unarchive object") : (t("archive_object", L) || "Archive object")}
|
||||
</button>
|
||||
<button class="btn danger" @click=${this._onDelete} ?disabled=${this._busy}>
|
||||
<ha-icon icon="mdi:delete"></ha-icon>
|
||||
${t("delete", L) || "Delete"}
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
`
|
||||
: html`<div class="loading">${t("loading", L) || "Loading…"}</div>`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderMetaRow(obj: MaintenanceObject) {
|
||||
const L = this._lang;
|
||||
const items: Array<[string, string]> = [];
|
||||
if (obj.area_id) items.push([t("area", L), obj.area_id]);
|
||||
if (obj.manufacturer) items.push([t("manufacturer", L), obj.manufacturer]);
|
||||
if (obj.model) items.push([t("model", L), obj.model]);
|
||||
if (obj.serial_number) items.push([t("serial_number_label", L), obj.serial_number]);
|
||||
if (obj.installation_date) items.push([t("installed", L), obj.installation_date]);
|
||||
if (obj.warranty_expiry) items.push([t("warranty", L), obj.warranty_expiry]);
|
||||
if (obj.documentation_url) items.push([t("documentation_url_label", L), obj.documentation_url]);
|
||||
|
||||
if (items.length === 0) return nothing;
|
||||
return html`
|
||||
<div class="meta">
|
||||
${items.map(
|
||||
([label, value]) => html`
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">${label}</span>
|
||||
<span class="meta-value">${
|
||||
// Only render http(s) values as links (never javascript:/data:);
|
||||
// value-based so it works in every UI language, not just English.
|
||||
/^https?:\/\//i.test(value)
|
||||
? html`<a href="${value}" target="_blank" rel="noopener noreferrer">${value}</a>`
|
||||
: value
|
||||
}</span>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host { display: contents; }
|
||||
.backdrop {
|
||||
position: fixed; inset: 0; z-index: 100; background: rgba(0,0,0,0.5);
|
||||
}
|
||||
.dialog {
|
||||
position: fixed; left: 50%; top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 95vw; max-width: 480px;
|
||||
max-height: 92vh; overflow: auto;
|
||||
background: var(--card-background-color, var(--ha-card-background, #1c1c1c));
|
||||
color: var(--primary-text-color);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.4);
|
||||
padding: 20px; z-index: 101;
|
||||
display: flex; flex-direction: column; gap: 14px;
|
||||
}
|
||||
.header { display: flex; flex-direction: column; gap: 6px; }
|
||||
.title { font-size: 20px; font-weight: 600; }
|
||||
.meta { display: flex; flex-direction: column; gap: 4px; padding-top: 4px; border-top: 1px solid var(--divider-color); }
|
||||
.meta-item { display: flex; gap: 8px; font-size: 12px; }
|
||||
.meta-label { color: var(--secondary-text-color); min-width: 100px; }
|
||||
.meta-value { color: var(--primary-text-color); flex: 1; word-break: break-word; }
|
||||
.meta-value a { color: var(--primary-color); }
|
||||
.tasks-section, .notes-section { display: flex; flex-direction: column; gap: 6px; }
|
||||
.section-header { display: flex; align-items: baseline; gap: 8px; }
|
||||
.count {
|
||||
font-size: 11px; color: var(--secondary-text-color);
|
||||
background: var(--secondary-background-color); padding: 2px 8px; border-radius: 999px;
|
||||
}
|
||||
.empty { color: var(--secondary-text-color); font-style: italic; font-size: 13px; padding: 8px 0; }
|
||||
.task-list { display: flex; flex-direction: column; gap: 4px; max-height: 200px; overflow: auto; }
|
||||
.task-row {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px; border-radius: 6px; cursor: pointer;
|
||||
background: var(--secondary-background-color, rgba(255,255,255,0.03));
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.task-row:hover { background: var(--state-icon-color, rgba(255,255,255,0.06)); }
|
||||
.status-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||
.task-name { flex: 1; font-size: 14px; }
|
||||
.task-status { font-size: 11px; color: var(--secondary-text-color); text-transform: uppercase; }
|
||||
.notes-body { white-space: pre-wrap; font-size: 13px; padding: 8px; background: var(--secondary-background-color); border-radius: 6px; }
|
||||
.actions { display: flex; gap: 8px; padding-top: 8px; border-top: 1px solid var(--divider-color); }
|
||||
.actions .btn { flex: 1; }
|
||||
.btn {
|
||||
padding: 8px; font-size: 13px; border-radius: 6px; cursor: pointer;
|
||||
border: 1px solid var(--divider-color);
|
||||
background: var(--secondary-background-color, transparent);
|
||||
color: var(--primary-text-color); font-weight: 500;
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
||||
}
|
||||
.btn:hover { background: var(--state-icon-color, rgba(255,255,255,0.06)); }
|
||||
.btn[disabled] { opacity: 0.5; cursor: wait; }
|
||||
.btn.primary { background: var(--primary-color); color: var(--text-primary-color, white); border-color: var(--primary-color); }
|
||||
.btn.danger { color: var(--error-color); }
|
||||
.btn ha-icon { --mdc-icon-size: 16px; }
|
||||
.loading { padding: 24px; text-align: center; color: var(--secondary-text-color); }
|
||||
.error { padding: 8px; border-radius: 6px; background: rgba(211,47,47,0.1); color: var(--error-color); font-size: 13px; }
|
||||
`;
|
||||
}
|
||||
|
||||
if (!customElements.get("maintenance-object-quick-actions-dialog")) {
|
||||
customElements.define(
|
||||
"maintenance-object-quick-actions-dialog",
|
||||
MaintenanceObjectQuickActionsDialog,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
/** Dialog for generating, printing, and downloading QR codes.
|
||||
*
|
||||
* For tasks: shows two QR codes side-by-side — "Info" (ℹ) and "Complete" (✓)
|
||||
* with embedded icons. For objects: shows a single "Info" QR.
|
||||
*/
|
||||
|
||||
import { LitElement, html, css, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { t } from "../styles";
|
||||
|
||||
interface QrResult {
|
||||
svg_data_uri: string;
|
||||
url: string;
|
||||
label: {
|
||||
object_name: string;
|
||||
manufacturer: string;
|
||||
model: string;
|
||||
task_name: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
/** Escape HTML entities to prevent XSS in document.write contexts. */
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
/** Sanitize a data URI for safe use in an img src attribute. */
|
||||
function sanitizeDataUri(uri: string): string {
|
||||
if (!uri.startsWith("data:image/svg+xml,") && !uri.startsWith("data:image/png;base64,")) {
|
||||
return "";
|
||||
}
|
||||
return escapeHtml(uri);
|
||||
}
|
||||
|
||||
/** Sanitize a string for use in a filename (remove OS-invalid chars). */
|
||||
function sanitizeFilename(s: string): string {
|
||||
return s.replace(/[/\\:*?"<>|#%]+/g, "").replace(/\s+/g, "-").toLowerCase().substring(0, 100);
|
||||
}
|
||||
|
||||
export class MaintenanceQrDialog extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@property() public lang = "en";
|
||||
|
||||
@state() private _open = false;
|
||||
@state() private _loading = false;
|
||||
@state() private _error = "";
|
||||
@state() private _viewResult: QrResult | null = null;
|
||||
@state() private _completeResult: QrResult | null = null;
|
||||
@state() private _urlMode: "companion" | "local" | "server" = "companion";
|
||||
|
||||
private _entryId = "";
|
||||
private _taskId: string | null = null;
|
||||
private _objectName = "";
|
||||
private _taskName = "";
|
||||
private _generateSeq = 0;
|
||||
|
||||
public openForObject(entryId: string, objectName: string): void {
|
||||
this._entryId = entryId;
|
||||
this._taskId = null;
|
||||
this._objectName = objectName;
|
||||
this._taskName = "";
|
||||
this._urlMode = "companion";
|
||||
this._error = "";
|
||||
this._viewResult = null;
|
||||
this._completeResult = null;
|
||||
this._open = true;
|
||||
this._generate();
|
||||
}
|
||||
|
||||
public openForTask(
|
||||
entryId: string,
|
||||
taskId: string,
|
||||
objectName: string,
|
||||
taskName: string,
|
||||
): void {
|
||||
this._entryId = entryId;
|
||||
this._taskId = taskId;
|
||||
this._objectName = objectName;
|
||||
this._taskName = taskName;
|
||||
this._urlMode = "companion";
|
||||
this._error = "";
|
||||
this._viewResult = null;
|
||||
this._completeResult = null;
|
||||
this._open = true;
|
||||
this._generate();
|
||||
}
|
||||
|
||||
private async _generate(): Promise<void> {
|
||||
const seq = ++this._generateSeq;
|
||||
this._loading = true;
|
||||
this._error = "";
|
||||
this._viewResult = null;
|
||||
this._completeResult = null;
|
||||
try {
|
||||
const base: Record<string, unknown> = {
|
||||
type: "maintenance_supporter/qr/generate",
|
||||
entry_id: this._entryId,
|
||||
url_mode: this._urlMode,
|
||||
};
|
||||
if (this._taskId) base.task_id = this._taskId;
|
||||
|
||||
// Always request "view" QR; also request "complete" if this is a task
|
||||
const promises: Promise<unknown>[] = [
|
||||
this.hass.connection.sendMessagePromise({ ...base, action: "view" }),
|
||||
];
|
||||
if (this._taskId) {
|
||||
promises.push(
|
||||
this.hass.connection.sendMessagePromise({ ...base, action: "complete" }),
|
||||
);
|
||||
}
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
if (seq !== this._generateSeq) return;
|
||||
|
||||
this._viewResult = results[0] as QrResult;
|
||||
if (results.length > 1) {
|
||||
this._completeResult = results[1] as QrResult;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (seq !== this._generateSeq) return;
|
||||
const code = (err as Record<string, unknown>)?.code;
|
||||
const msg = (err as Record<string, unknown>)?.message;
|
||||
this._error = code === "no_url" || (typeof msg === "string" && msg.includes("No Home Assistant URL"))
|
||||
? t("qr_error_no_url", this.lang)
|
||||
: t("qr_error", this.lang);
|
||||
} finally {
|
||||
if (seq === this._generateSeq) this._loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _setUrlMode(mode: "companion" | "local" | "server"): void {
|
||||
if (this._urlMode === mode) return;
|
||||
this._urlMode = mode;
|
||||
this._generate();
|
||||
}
|
||||
|
||||
private _print(): void {
|
||||
if (!this._viewResult) return;
|
||||
const r = this._viewResult;
|
||||
const title = r.label.task_name
|
||||
? `${r.label.object_name} — ${r.label.task_name}`
|
||||
: r.label.object_name;
|
||||
const subtitle = [r.label.manufacturer, r.label.model]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const w = window.open("", "_blank", "width=600,height=500");
|
||||
if (!w) return;
|
||||
const L = this.lang || "en";
|
||||
const safeTitle = escapeHtml(title);
|
||||
const safeSub = escapeHtml(subtitle);
|
||||
|
||||
const hasComplete = !!this._completeResult;
|
||||
const viewLabel = escapeHtml(t("qr_action_view", L));
|
||||
const completeLabel = escapeHtml(t("qr_action_complete", L));
|
||||
|
||||
w.document.write(`<!DOCTYPE html><html><head><meta charset="utf-8">
|
||||
<title>${safeTitle}</title>
|
||||
<style>
|
||||
body{font-family:sans-serif;text-align:center;padding:20px}
|
||||
h2{margin:0 0 4px}
|
||||
.sub{color:#666;font-size:14px;margin-bottom:16px}
|
||||
.qr-row{display:flex;justify-content:center;gap:24px;margin:12px 0}
|
||||
.qr-col{display:flex;flex-direction:column;align-items:center;gap:6px}
|
||||
.qr-col img{width:${hasComplete ? "200px" : "280px"}}
|
||||
.qr-label{font-size:13px;font-weight:500;color:#333}
|
||||
.url{font-size:10px;color:#999;word-break:break-all;margin-top:8px;max-width:480px}
|
||||
</style></head><body>
|
||||
<h2>${safeTitle}</h2>
|
||||
${safeSub ? `<div class="sub">${safeSub}</div>` : ""}
|
||||
<div class="qr-row">
|
||||
<div class="qr-col">
|
||||
<img src="${sanitizeDataUri(this._viewResult.svg_data_uri)}" alt="QR Info" />
|
||||
<div class="qr-label">${viewLabel}</div>
|
||||
</div>
|
||||
${hasComplete ? `<div class="qr-col">
|
||||
<img src="${sanitizeDataUri(this._completeResult!.svg_data_uri)}" alt="QR Complete" />
|
||||
<div class="qr-label">${completeLabel}</div>
|
||||
</div>` : ""}
|
||||
</div>
|
||||
<div class="url">${escapeHtml(this._viewResult.url)}</div>
|
||||
<script>setTimeout(()=>window.print(),300)<\/script>
|
||||
</body></html>`);
|
||||
w.document.close();
|
||||
}
|
||||
|
||||
private _downloadSvg(result: QrResult, suffix: string): void {
|
||||
const svgContent = decodeURIComponent(
|
||||
result.svg_data_uri.replace("data:image/svg+xml,", ""),
|
||||
);
|
||||
const blob = new Blob([svgContent], { type: "image/svg+xml" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
const name = this._taskName
|
||||
? `${this._objectName}-${this._taskName}`
|
||||
: this._objectName;
|
||||
a.download = `qr-${sanitizeFilename(name)}-${suffix}.svg`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
private _close(): void {
|
||||
this._open = false;
|
||||
this._viewResult = null;
|
||||
this._completeResult = null;
|
||||
this._error = "";
|
||||
this._loading = false;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this._open) return html``;
|
||||
const L = this.lang || this.hass?.language || "en";
|
||||
const heading = this._taskName
|
||||
? `${t("qr_code", L)}: ${this._objectName} — ${this._taskName}`
|
||||
: `${t("qr_code", L)}: ${this._objectName}`;
|
||||
const hasResults = !!this._viewResult;
|
||||
return html`
|
||||
<ha-dialog open @closed=${this._close}>
|
||||
<div class="dialog-title">${heading}</div>
|
||||
<div class="content">
|
||||
${this._loading
|
||||
? html`<div class="loading">${t("qr_generating", L)}</div>`
|
||||
: this._error
|
||||
? html`<div class="error">${this._error}</div>`
|
||||
: hasResults
|
||||
? html`
|
||||
<div class="qr-pair">
|
||||
<div class="qr-item">
|
||||
<img
|
||||
class="qr-image ${this._completeResult ? "small" : ""}"
|
||||
src="${this._viewResult!.svg_data_uri}"
|
||||
alt="QR Info"
|
||||
/>
|
||||
<div class="qr-item-label">${t("qr_action_view", L)}</div>
|
||||
<button class="dl-btn"
|
||||
@click=${() => this._downloadSvg(this._viewResult!, "info")}>
|
||||
<ha-icon icon="mdi:download"></ha-icon>
|
||||
${t("qr_download", L)}
|
||||
</button>
|
||||
</div>
|
||||
${this._completeResult
|
||||
? html`
|
||||
<div class="qr-item">
|
||||
<img
|
||||
class="qr-image small"
|
||||
src="${this._completeResult.svg_data_uri}"
|
||||
alt="QR Complete"
|
||||
/>
|
||||
<div class="qr-item-label">${t("qr_action_complete", L)}</div>
|
||||
<button class="dl-btn"
|
||||
@click=${() => this._downloadSvg(this._completeResult!, "complete")}>
|
||||
<ha-icon icon="mdi:download"></ha-icon>
|
||||
${t("qr_download", L)}
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
<div class="url-display">${this._viewResult!.url}</div>
|
||||
`
|
||||
: nothing}
|
||||
<div class="action-row">
|
||||
<label>${t("qr_url_mode", L)}</label>
|
||||
<div class="action-toggle">
|
||||
<button class="toggle-btn ${this._urlMode === "companion" ? "active" : ""}"
|
||||
@click=${() => this._setUrlMode("companion")}>${t("qr_mode_companion", L)}</button>
|
||||
<button class="toggle-btn ${this._urlMode === "local" ? "active" : ""}"
|
||||
@click=${() => this._setUrlMode("local")}>${t("qr_mode_local", L)}</button>
|
||||
<button class="toggle-btn ${this._urlMode === "server" ? "active" : ""}"
|
||||
@click=${() => this._setUrlMode("server")}>${t("qr_mode_server", L)}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dialog-actions">
|
||||
<ha-button appearance="plain" @click=${this._close}>
|
||||
${t("cancel", L)}
|
||||
</ha-button>
|
||||
<ha-button
|
||||
@click=${this._print}
|
||||
.disabled=${!hasResults}
|
||||
>
|
||||
${t("qr_print", L)}
|
||||
</ha-button>
|
||||
</div>
|
||||
</ha-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
.dialog-title {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
min-width: 300px;
|
||||
}
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding-top: 16px;
|
||||
}
|
||||
.qr-pair {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
.qr-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.qr-image {
|
||||
width: 240px;
|
||||
height: 240px;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
.qr-image.small {
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
}
|
||||
.qr-item-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--secondary-text-color);
|
||||
text-align: center;
|
||||
}
|
||||
.dl-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: none;
|
||||
border: 1px solid var(--divider-color, #e0e0e0);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--primary-text-color);
|
||||
padding: 6px 14px;
|
||||
border-radius: 18px;
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
}
|
||||
.dl-btn:hover {
|
||||
background: var(--secondary-background-color, #f5f5f5);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
.dl-btn ha-icon {
|
||||
--mdc-icon-size: 18px;
|
||||
}
|
||||
.url-display {
|
||||
font-size: 11px;
|
||||
color: var(--secondary-text-color);
|
||||
word-break: break-all;
|
||||
text-align: center;
|
||||
max-width: 400px;
|
||||
}
|
||||
.loading {
|
||||
padding: 40px 0;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.error {
|
||||
padding: 20px 0;
|
||||
color: var(--error-color, #f44336);
|
||||
}
|
||||
.action-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
.action-row label {
|
||||
font-size: 13px;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.action-toggle {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
background: var(--divider-color, #e0e0e0);
|
||||
border-radius: 6px;
|
||||
padding: 3px;
|
||||
}
|
||||
.toggle-btn {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--primary-text-color);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.toggle-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
.toggle-btn.active {
|
||||
background: var(--primary-color);
|
||||
color: var(--text-primary-color, #fff);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
if (!customElements.get("maintenance-qr-dialog")) {
|
||||
customElements.define("maintenance-qr-dialog", MaintenanceQrDialog);
|
||||
}
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
/** Dialog for editing manual seasonal factor overrides (12 months). */
|
||||
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
|
||||
import { t } from "../styles";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
const MONTH_KEYS = [
|
||||
"month_jan", "month_feb", "month_mar", "month_apr",
|
||||
"month_may", "month_jun", "month_jul", "month_aug",
|
||||
"month_sep", "month_oct", "month_nov", "month_dec",
|
||||
];
|
||||
|
||||
export class SeasonalOverridesDialog extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@state() private _open = false;
|
||||
@state() private _loading = false;
|
||||
@state() private _error = "";
|
||||
@state() private _entryId = "";
|
||||
@state() private _taskId = "";
|
||||
@state() private _values: string[] = new Array(12).fill("");
|
||||
|
||||
private get _lang(): string {
|
||||
return this.hass?.language ?? navigator.language.split("-")[0] ?? "en";
|
||||
}
|
||||
|
||||
public open(entryId: string, taskId: string, currentOverrides: Record<number, number> | null | undefined): void {
|
||||
this._entryId = entryId;
|
||||
this._taskId = taskId;
|
||||
this._values = new Array(12).fill("");
|
||||
if (currentOverrides) {
|
||||
for (const [k, v] of Object.entries(currentOverrides)) {
|
||||
const m = parseInt(k, 10);
|
||||
if (m >= 1 && m <= 12 && typeof v === "number") {
|
||||
this._values[m - 1] = v.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
this._error = "";
|
||||
this._open = true;
|
||||
}
|
||||
|
||||
private _close(): void {
|
||||
this._open = false;
|
||||
}
|
||||
|
||||
private _buildOverrides(): Record<number, number> | null {
|
||||
const out: Record<number, number> = {};
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const raw = this._values[i].trim();
|
||||
if (!raw) continue;
|
||||
const num = parseFloat(raw);
|
||||
if (Number.isNaN(num)) {
|
||||
this._error = `${t("month_" + ["jan","feb","mar","apr","may","jun","jul","aug","sep","oct","nov","dec"][i], this._lang)}: ${t("seasonal_override_invalid", this._lang)}`;
|
||||
return null;
|
||||
}
|
||||
if (num < 0.1 || num > 5.0) {
|
||||
this._error = t("seasonal_override_range", this._lang);
|
||||
return null;
|
||||
}
|
||||
out[i + 1] = num;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private _save = async (): Promise<void> => {
|
||||
const overrides = this._buildOverrides();
|
||||
if (overrides === null) return;
|
||||
this._loading = true;
|
||||
this._error = "";
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/task/seasonal_overrides",
|
||||
entry_id: this._entryId,
|
||||
task_id: this._taskId,
|
||||
overrides,
|
||||
});
|
||||
this._open = false;
|
||||
this.dispatchEvent(new CustomEvent("overrides-saved"));
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang, t("save_error", this._lang));
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
private _clearAll = async (): Promise<void> => {
|
||||
this._loading = true;
|
||||
this._error = "";
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/task/seasonal_overrides",
|
||||
entry_id: this._entryId,
|
||||
task_id: this._taskId,
|
||||
overrides: {},
|
||||
});
|
||||
this._values = new Array(12).fill("");
|
||||
this._open = false;
|
||||
this.dispatchEvent(new CustomEvent("overrides-saved"));
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang, t("save_error", this._lang));
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
if (!this._open) return html``;
|
||||
const L = this._lang;
|
||||
return html`
|
||||
<ha-dialog open @closed=${this._close} heading="${t("seasonal_overrides_title", L)}">
|
||||
<div class="content">
|
||||
<p class="hint">${t("seasonal_overrides_hint", L)}</p>
|
||||
${this._error ? html`<div class="error">${this._error}</div>` : nothing}
|
||||
<div class="months">
|
||||
${MONTH_KEYS.map((key, i) => html`
|
||||
<label class="month">
|
||||
<span class="mn">${t(key, L)}</span>
|
||||
<input type="number" step="0.1" min="0.1" max="5.0"
|
||||
placeholder="1.0"
|
||||
.value=${this._values[i]}
|
||||
@input=${(e: Event) => {
|
||||
const v = [...this._values];
|
||||
v[i] = (e.target as HTMLInputElement).value;
|
||||
this._values = v;
|
||||
}} />
|
||||
</label>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="dialog-actions">
|
||||
<ha-button appearance="plain" @click=${this._clearAll} .disabled=${this._loading}>
|
||||
${t("clear_all", L)}
|
||||
</ha-button>
|
||||
<div class="spacer"></div>
|
||||
<ha-button appearance="plain" @click=${this._close}>
|
||||
${t("cancel", L)}
|
||||
</ha-button>
|
||||
<ha-button @click=${this._save} .disabled=${this._loading}>
|
||||
${this._loading ? t("saving", L) : t("save", L)}
|
||||
</ha-button>
|
||||
</div>
|
||||
</ha-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
.content {
|
||||
min-width: 320px;
|
||||
max-width: 480px;
|
||||
}
|
||||
.hint {
|
||||
color: var(--secondary-text-color);
|
||||
font-size: 13px;
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
.error {
|
||||
color: var(--error-color, #f44336);
|
||||
font-size: 13px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.months {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
.month {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.mn {
|
||||
min-width: 70px;
|
||||
font-size: 14px;
|
||||
}
|
||||
input[type="number"] {
|
||||
flex: 1;
|
||||
padding: 6px 8px;
|
||||
font-size: 14px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--divider-color);
|
||||
background: var(--card-background-color);
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-top: 16px;
|
||||
}
|
||||
.spacer { flex: 1; }
|
||||
`;
|
||||
}
|
||||
|
||||
if (!customElements.get("maintenance-seasonal-overrides-dialog")) {
|
||||
customElements.define("maintenance-seasonal-overrides-dialog", SeasonalOverridesDialog);
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
/** Shared CSS for the 3 interactive section cards (vacation/budget/groups).
|
||||
*
|
||||
* Extracted via the DRY-audit (Tier 2). Each card was carrying ~30 LOC of
|
||||
* identical button + header + emoji + error + loading styles plus tiny
|
||||
* divergences in `.card-content { gap }` (12px vs 14px). This file owns
|
||||
* the canonical version; cards import + extend it for their card-specific
|
||||
* layout.
|
||||
*
|
||||
* When adding more section cards (Notifications / Panel Access etc.):
|
||||
*
|
||||
* static styles = [sectionCardSharedStyles, css`...card-specific...`];
|
||||
*
|
||||
* Don't add card-specific selectors here — they belong in the card file.
|
||||
* This module is only for selectors that ALL section cards use.
|
||||
*/
|
||||
|
||||
import { css } from "lit";
|
||||
|
||||
export const sectionCardSharedStyles = css`
|
||||
ha-card { overflow: hidden; }
|
||||
.card-content {
|
||||
padding: 16px;
|
||||
display: flex; flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.title {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
font-size: 16px; font-weight: 500;
|
||||
}
|
||||
.emoji { font-size: 20px; }
|
||||
|
||||
/* Button family — primary action / muted-saved-state / link / icon-with-text */
|
||||
.btn {
|
||||
padding: 6px 12px; font-size: 13px;
|
||||
border-radius: 6px; cursor: pointer;
|
||||
border: 1px solid var(--divider-color);
|
||||
background: var(--secondary-background-color, transparent);
|
||||
color: var(--primary-text-color);
|
||||
font-weight: 500;
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
}
|
||||
.btn:hover { background: var(--state-icon-color, rgba(255,255,255,0.06)); }
|
||||
.btn[disabled] { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn.primary {
|
||||
background: var(--primary-color);
|
||||
color: var(--text-primary-color, white);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
.btn.primary[disabled] { opacity: 0.6; }
|
||||
.btn.muted {
|
||||
background: transparent;
|
||||
color: var(--secondary-text-color);
|
||||
border-style: dashed;
|
||||
}
|
||||
.btn.muted[disabled] { opacity: 1; cursor: default; }
|
||||
.btn.muted ha-icon, .btn.primary ha-icon { --mdc-icon-size: 14px; }
|
||||
.btn.link {
|
||||
background: transparent; border: none; padding: 6px 4px;
|
||||
color: var(--primary-color); margin-left: auto;
|
||||
}
|
||||
.btn.link:hover { background: transparent; text-decoration: underline; }
|
||||
|
||||
/* Error + loading states */
|
||||
.error {
|
||||
padding: 8px; border-radius: 6px;
|
||||
background: rgba(211, 47, 47, 0.1);
|
||||
color: var(--error-color, #d32f2f); font-size: 13px;
|
||||
}
|
||||
.loading {
|
||||
padding: 24px; text-align: center;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
`;
|
||||
File diff suppressed because it is too large
Load Diff
+361
@@ -0,0 +1,361 @@
|
||||
/** Document-storage overview card (panel overview).
|
||||
*
|
||||
* Shows the physical footprint (real backup cost), the dedup saving, and a
|
||||
* per-object drill-down sorted by size. Object ids from the WS summary are
|
||||
* mapped to names via the panel's already-loaded objects. Self-hides when no
|
||||
* documents exist, so it never clutters the overview for non-users.
|
||||
*/
|
||||
|
||||
import { LitElement, html, css, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { t, ensureLocale } from "../styles";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { formatBytes } from "../helpers/format-bytes";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
interface StorageSummary {
|
||||
total_bytes: number;
|
||||
dedup_savings_bytes: number;
|
||||
file_count: number;
|
||||
link_count: number;
|
||||
document_count: number;
|
||||
by_object: Record<string, { bytes: number; files: number; links: number }>;
|
||||
}
|
||||
|
||||
interface PanelObject {
|
||||
entry_id: string;
|
||||
object?: { id?: string; name?: string };
|
||||
}
|
||||
|
||||
interface SearchResult {
|
||||
id: string;
|
||||
entry_id: string;
|
||||
object_name: string;
|
||||
kind: "file" | "weblink";
|
||||
title: string;
|
||||
filename?: string;
|
||||
url?: string;
|
||||
size?: number;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export class MaintenanceStorageSectionCard extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@property({ attribute: false }) public objects: PanelObject[] = [];
|
||||
|
||||
@state() private _summary: StorageSummary | null = null;
|
||||
@state() private _loaded = false;
|
||||
@state() private _busy = false;
|
||||
@state() private _error = "";
|
||||
@state() private _query = "";
|
||||
@state() private _results: SearchResult[] = [];
|
||||
@state() private _expanded = false;
|
||||
|
||||
private _initiallyLoaded = false;
|
||||
private _searchTimer = 0;
|
||||
|
||||
private get _lang(): string {
|
||||
return this.hass?.language || "en";
|
||||
}
|
||||
|
||||
updated(changed: Map<string, unknown>): void {
|
||||
super.updated(changed);
|
||||
if (changed.has("hass") && this.hass && !this._initiallyLoaded) {
|
||||
this._initiallyLoaded = true;
|
||||
void this._load();
|
||||
// Re-render once the runtime locale JSON arrives (t() falls back to
|
||||
// English until then; this card can paint before the panel's fetch lands).
|
||||
void ensureLocale(this._lang).then(() => this.requestUpdate());
|
||||
}
|
||||
}
|
||||
|
||||
private async _load(): Promise<void> {
|
||||
this._busy = true;
|
||||
try {
|
||||
this._summary = await this.hass.connection.sendMessagePromise<StorageSummary>({
|
||||
type: "maintenance_supporter/documents/storage",
|
||||
});
|
||||
this._error = "";
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._loaded = true;
|
||||
this._busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _nameFor(objectId: string): string {
|
||||
const o = this.objects.find((x) => x.object?.id === objectId);
|
||||
return o?.object?.name || objectId.slice(0, 8);
|
||||
}
|
||||
|
||||
private _entryFor(objectId: string): string | undefined {
|
||||
return this.objects.find((x) => x.object?.id === objectId)?.entry_id;
|
||||
}
|
||||
|
||||
private _toggle(): void {
|
||||
this._expanded = !this._expanded;
|
||||
}
|
||||
|
||||
/** Ask the panel (which owns navigation) to open an object's detail view. */
|
||||
private _openObject(entryId: string): void {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("open-object", {
|
||||
detail: { entry_id: entryId },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private _onSearch(e: Event): void {
|
||||
this._query = (e.target as HTMLInputElement).value;
|
||||
clearTimeout(this._searchTimer);
|
||||
this._searchTimer = window.setTimeout(() => void this._doSearch(), 250);
|
||||
}
|
||||
|
||||
private async _doSearch(): Promise<void> {
|
||||
const q = this._query.trim();
|
||||
if (!q) {
|
||||
this._results = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await this.hass.connection.sendMessagePromise<{ results: SearchResult[] }>({
|
||||
type: "maintenance_supporter/documents/search",
|
||||
query: q,
|
||||
});
|
||||
this._results = r.results || [];
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
this._results = [];
|
||||
}
|
||||
}
|
||||
|
||||
private async _openResult(doc: SearchResult): Promise<void> {
|
||||
if (doc.kind === "weblink") {
|
||||
window.open(doc.url, "_blank", "noopener");
|
||||
return;
|
||||
}
|
||||
const win = window.open("about:blank", "_blank");
|
||||
try {
|
||||
const s = await this.hass.connection.sendMessagePromise<{ path: string }>({
|
||||
type: "auth/sign_path",
|
||||
path: `/api/maintenance_supporter/document/${doc.id}`,
|
||||
expires: 300,
|
||||
});
|
||||
// Absolute URL so it navigates the blank popup reliably (about:blank base).
|
||||
if (win) win.location.href = new URL(s.path, window.location.origin).href;
|
||||
} catch (e) {
|
||||
if (win) win.close();
|
||||
this._error = describeWsError(e, this._lang);
|
||||
}
|
||||
}
|
||||
|
||||
private _renderResult(doc: SearchResult, L: string) {
|
||||
return html`
|
||||
<div class="obj-row result-row" title=${t("doc_open", L)} @click=${() => this._openResult(doc)}>
|
||||
<ha-icon icon=${doc.kind === "weblink" ? "mdi:link-variant" : "mdi:file-document-outline"}></ha-icon>
|
||||
<div class="result-info">
|
||||
<div class="result-title">${doc.title || doc.filename || doc.url}</div>
|
||||
<div class="result-obj">${doc.object_name}</div>
|
||||
</div>
|
||||
<ha-icon class="result-open" icon=${doc.kind === "weblink" ? "mdi:open-in-new" : "mdi:eye-outline"}></ha-icon>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this._loaded || !this._summary) return nothing;
|
||||
const s = this._summary;
|
||||
if (s.document_count === 0) return nothing; // self-hide when unused
|
||||
const L = this._lang;
|
||||
|
||||
const rows = Object.entries(s.by_object)
|
||||
.filter(([, v]) => v.files > 0 || v.links > 0)
|
||||
.map(([id, v]) => ({ id, name: this._nameFor(id), entry: this._entryFor(id), ...v }))
|
||||
.sort((a, b) => b.bytes - a.bytes);
|
||||
|
||||
return html`
|
||||
<ha-card>
|
||||
<div class="card-content">
|
||||
<div class="header">
|
||||
<button
|
||||
class="toggle"
|
||||
@click=${this._toggle}
|
||||
aria-expanded=${this._expanded ? "true" : "false"}
|
||||
aria-label=${t("doc_storage_title", L)}
|
||||
>
|
||||
<ha-icon class="chevron" icon=${this._expanded ? "mdi:chevron-down" : "mdi:chevron-right"}></ha-icon>
|
||||
<span class="emoji">🗄️</span>
|
||||
<span class="title-text">${t("doc_storage_title", L)}</span>
|
||||
<span class="header-summary">
|
||||
${formatBytes(s.total_bytes)}
|
||||
${s.dedup_savings_bytes > 0 ? html`<span class="saved">−${formatBytes(s.dedup_savings_bytes)}</span>` : nothing}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="icon-btn"
|
||||
title=${t("doc_storage_refresh", L)}
|
||||
?disabled=${this._busy}
|
||||
@click=${this._load}
|
||||
>
|
||||
<ha-icon icon="mdi:refresh"></ha-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${this._expanded
|
||||
? html`
|
||||
<div class="body">
|
||||
<div class="totals">
|
||||
<div class="stat">
|
||||
<div class="stat-value">${formatBytes(s.total_bytes)}</div>
|
||||
<div class="stat-label">
|
||||
<ha-icon icon="mdi:file-document-outline"></ha-icon> ${s.file_count}
|
||||
<ha-icon icon="mdi:link-variant"></ha-icon> ${s.link_count}
|
||||
</div>
|
||||
</div>
|
||||
${s.dedup_savings_bytes > 0
|
||||
? html`<div class="stat">
|
||||
<div class="stat-value saved">−${formatBytes(s.dedup_savings_bytes)}</div>
|
||||
<div class="stat-label">${t("doc_storage_saved", L)}</div>
|
||||
</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
|
||||
<div class="doc-search">
|
||||
<ha-icon icon="mdi:magnify"></ha-icon>
|
||||
<input
|
||||
type="search"
|
||||
aria-label=${t("doc_search", L)}
|
||||
placeholder=${t("doc_search", L)}
|
||||
.value=${this._query}
|
||||
@input=${this._onSearch}
|
||||
/>
|
||||
</div>
|
||||
|
||||
${this._error ? html`<div class="error">${this._error}</div>` : nothing}
|
||||
|
||||
${this._query.trim()
|
||||
? this._results.length
|
||||
? html`<div class="obj-list">${this._results.map((d) => this._renderResult(d, L))}</div>`
|
||||
: html`<div class="search-empty">${t("doc_search_none", L)}</div>`
|
||||
: rows.length
|
||||
? html`<div class="obj-list">${rows.map((r) => this._renderObjRow(r, L))}</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderObjRow(
|
||||
r: { id: string; name: string; entry?: string; bytes: number; files: number; links: number },
|
||||
L: string,
|
||||
) {
|
||||
const eid = r.entry;
|
||||
return html`
|
||||
<div
|
||||
class="obj-row ${eid ? "clickable" : ""}"
|
||||
role=${eid ? "button" : nothing}
|
||||
tabindex=${eid ? "0" : nothing}
|
||||
aria-label=${eid ? r.name : nothing}
|
||||
@click=${eid ? () => this._openObject(eid) : undefined}
|
||||
@keydown=${eid
|
||||
? (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
this._openObject(eid);
|
||||
}
|
||||
}
|
||||
: undefined}
|
||||
>
|
||||
<span class="obj-name">${r.name}</span>
|
||||
<span class="obj-meta">
|
||||
${r.files > 0 ? html`<ha-icon icon="mdi:file-document-outline"></ha-icon>${r.files}` : nothing}
|
||||
${r.links > 0 ? html`<ha-icon icon="mdi:link-variant"></ha-icon>${r.links}` : nothing}
|
||||
</span>
|
||||
<span class="obj-size">${formatBytes(r.bytes)}</span>
|
||||
${eid ? html`<ha-icon class="obj-go" icon="mdi:chevron-right"></ha-icon>` : nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
ha-card { margin-top: 16px; }
|
||||
.card-content { padding: 16px; }
|
||||
.doc-search {
|
||||
display: flex; align-items: center; gap: 6px; margin: 10px 0 4px;
|
||||
padding: 2px 10px; border-radius: 8px;
|
||||
background: var(--secondary-background-color, rgba(0, 0, 0, 0.06));
|
||||
border: 1px solid var(--divider-color);
|
||||
}
|
||||
.doc-search ha-icon { --mdc-icon-size: 18px; color: var(--secondary-text-color, #888); }
|
||||
.doc-search input {
|
||||
flex: 1; border: none; background: transparent; font: inherit; outline: none;
|
||||
color: var(--primary-text-color); padding: 6px 0;
|
||||
}
|
||||
.result-row { cursor: pointer; }
|
||||
.result-row > ha-icon { color: var(--primary-color); --mdc-icon-size: 20px; flex: none; }
|
||||
.result-info { flex: 1; min-width: 0; }
|
||||
.result-title { font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.result-obj { font-size: 12px; color: var(--secondary-text-color, #888); }
|
||||
.result-open { color: var(--secondary-text-color, #888); --mdc-icon-size: 18px; flex: none; }
|
||||
.search-empty { color: var(--secondary-text-color, #888); font-size: 13px; padding: 8px 2px; }
|
||||
.header { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.toggle {
|
||||
display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0;
|
||||
background: none; border: none; padding: 4px 0; margin: 0; cursor: pointer;
|
||||
font: inherit; color: var(--primary-text-color); text-align: left;
|
||||
}
|
||||
.toggle:focus-visible { outline: 2px solid var(--primary-color); outline-offset: 2px; border-radius: 6px; }
|
||||
.chevron { --mdc-icon-size: 22px; color: var(--secondary-text-color, #888); flex: none; }
|
||||
.title-text { font-size: 16px; font-weight: 500; }
|
||||
.header-summary {
|
||||
margin-left: auto; display: flex; align-items: center; gap: 8px;
|
||||
font-size: 14px; font-weight: 600; white-space: nowrap;
|
||||
}
|
||||
.header-summary .saved { color: var(--success-color, #4caf50); font-weight: 500; }
|
||||
.emoji { font-size: 18px; }
|
||||
.body { margin-top: 4px; }
|
||||
.totals { display: flex; gap: 24px; margin: 12px 0 8px; flex-wrap: wrap; }
|
||||
.stat-value { font-size: 22px; font-weight: 600; }
|
||||
.stat-value.saved { color: var(--success-color, #4caf50); }
|
||||
.stat-label {
|
||||
font-size: 12px; color: var(--secondary-text-color, #888);
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
}
|
||||
.stat-label ha-icon { --mdc-icon-size: 15px; }
|
||||
.obj-list { display: flex; flex-direction: column; gap: 2px; margin-top: 8px; }
|
||||
.obj-row {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 6px 8px; border-radius: 6px;
|
||||
}
|
||||
.obj-row:nth-child(odd) { background: var(--secondary-background-color, rgba(0,0,0,0.04)); }
|
||||
.obj-row.clickable { cursor: pointer; }
|
||||
.obj-row.clickable:hover { background: var(--secondary-background-color, rgba(0,0,0,0.10)); }
|
||||
.obj-row.clickable:focus-visible { outline: 2px solid var(--primary-color); outline-offset: -2px; }
|
||||
.obj-go { --mdc-icon-size: 18px; color: var(--secondary-text-color, #888); flex: none; }
|
||||
.obj-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; }
|
||||
.obj-meta {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
color: var(--secondary-text-color, #888); font-size: 13px;
|
||||
}
|
||||
.obj-meta ha-icon { --mdc-icon-size: 15px; }
|
||||
.obj-size { font-variant-numeric: tabular-nums; font-size: 13px; min-width: 64px; text-align: right; }
|
||||
.icon-btn {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 32px; height: 32px; border-radius: 8px; cursor: pointer;
|
||||
background: transparent; border: none; color: var(--primary-text-color);
|
||||
}
|
||||
.icon-btn:hover { background: var(--secondary-background-color, rgba(0,0,0,0.06)); }
|
||||
.icon-btn[disabled] { opacity: 0.4; pointer-events: none; }
|
||||
.error { color: var(--error-color, #f44336); font-size: 13px; margin-top: 6px; }
|
||||
`;
|
||||
}
|
||||
|
||||
if (!customElements.get("maintenance-storage-section-card")) {
|
||||
customElements.define("maintenance-storage-section-card", MaintenanceStorageSectionCard);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,339 @@
|
||||
/** Documents linked to a maintenance task.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { LitElement, html, css, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { t, ensureLocale } from "../styles";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { downloadUrl } from "../helpers/download";
|
||||
import { formatBytes } from "../helpers/format-bytes";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
interface Doc {
|
||||
id: string;
|
||||
kind: "file" | "weblink";
|
||||
title: string;
|
||||
filename?: string;
|
||||
url?: string;
|
||||
mime?: string;
|
||||
size?: number;
|
||||
tags?: string[];
|
||||
task_ids?: string[];
|
||||
task_pages?: Record<string, number>;
|
||||
}
|
||||
|
||||
const CATEGORIES = ["manual", "warranty", "invoice", "spare_parts", "photo", "other"] as const;
|
||||
const CATEGORY_ICONS: Record<string, string> = {
|
||||
manual: "mdi:book-open-variant",
|
||||
warranty: "mdi:shield-check",
|
||||
invoice: "mdi:receipt-text-outline",
|
||||
spare_parts: "mdi:cog-outline",
|
||||
photo: "mdi:image-outline",
|
||||
other: "mdi:file-document-outline",
|
||||
};
|
||||
|
||||
export class MaintenanceTaskDocuments extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@property({ attribute: false }) public entryId!: string;
|
||||
@property({ attribute: false }) public taskId!: string;
|
||||
@property({ type: Boolean }) public canWrite = false;
|
||||
|
||||
@state() private _docs: Doc[] = [];
|
||||
@state() private _loaded = false;
|
||||
@state() private _busy = false;
|
||||
@state() private _error = "";
|
||||
@state() private _attachId = "";
|
||||
|
||||
private _loadedKey = "";
|
||||
private _localeReady = false;
|
||||
|
||||
private get _lang(): string {
|
||||
return this.hass?.language || "en";
|
||||
}
|
||||
|
||||
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) {
|
||||
this._loadedKey = key;
|
||||
void this._load();
|
||||
}
|
||||
}
|
||||
|
||||
private async _load(): Promise<void> {
|
||||
try {
|
||||
const r = await this.hass.connection.sendMessagePromise<{ documents: Doc[] }>({
|
||||
type: "maintenance_supporter/documents/list",
|
||||
entry_id: this.entryId,
|
||||
});
|
||||
this._docs = r.documents || [];
|
||||
this._loaded = true;
|
||||
this._error = "";
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
this._loaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
private _linked(): Doc[] {
|
||||
return this._docs.filter((d) => (d.task_ids || []).includes(this.taskId));
|
||||
}
|
||||
|
||||
private _available(): Doc[] {
|
||||
return this._docs.filter((d) => !(d.task_ids || []).includes(this.taskId));
|
||||
}
|
||||
|
||||
private async _setTaskIds(doc: Doc, taskIds: 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,
|
||||
});
|
||||
await this._load();
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _link(): void {
|
||||
const doc = this._docs.find((d) => d.id === this._attachId);
|
||||
if (!doc) return;
|
||||
this._attachId = "";
|
||||
void this._setTaskIds(doc, [...(doc.task_ids || []), this.taskId]);
|
||||
}
|
||||
|
||||
private _unlink(doc: Doc): void {
|
||||
void this._setTaskIds(doc, (doc.task_ids || []).filter((x) => x !== this.taskId));
|
||||
}
|
||||
|
||||
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). */
|
||||
private _pageFor(doc: Doc): number | undefined {
|
||||
return this._isPdf(doc) ? doc.task_pages?.[this.taskId] : undefined;
|
||||
}
|
||||
|
||||
private async _open(doc: Doc): Promise<void> {
|
||||
if (doc.kind === "weblink") {
|
||||
window.open(doc.url, "_blank", "noopener");
|
||||
return;
|
||||
}
|
||||
// A per-task page hint jumps straight to the relevant page via the PDF
|
||||
// viewer's #page=N fragment (client-side, so it never breaks the signature).
|
||||
const page = this._pageFor(doc);
|
||||
const frag = page ? `#page=${page}` : "";
|
||||
const win = window.open("about:blank", "_blank");
|
||||
try {
|
||||
const s = await this.hass.connection.sendMessagePromise<{ path: string }>({
|
||||
type: "auth/sign_path",
|
||||
path: `/api/maintenance_supporter/document/${doc.id}`,
|
||||
expires: 300,
|
||||
});
|
||||
// Absolute URL: a fragment on a *root-relative* path won't resolve against
|
||||
// the blank popup's about:blank base, so it would silently stay blank.
|
||||
if (win) win.location.href = new URL(s.path + frag, window.location.origin).href;
|
||||
} catch (e) {
|
||||
if (win) win.close();
|
||||
this._error = describeWsError(e, this._lang);
|
||||
}
|
||||
}
|
||||
|
||||
/** 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> {
|
||||
this._busy = true;
|
||||
this._error = "";
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/documents/update",
|
||||
doc_id: doc.id,
|
||||
task_pages: { [this.taskId]: page },
|
||||
});
|
||||
await this._load();
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async _download(doc: Doc): Promise<void> {
|
||||
try {
|
||||
const s = await this.hass.connection.sendMessagePromise<{ path: string }>({
|
||||
type: "auth/sign_path",
|
||||
path: `/api/maintenance_supporter/document/${doc.id}`,
|
||||
expires: 30,
|
||||
});
|
||||
downloadUrl(s.path, doc.filename || doc.title || "document");
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
// Hide entirely for objects with no documents — no clutter on every task.
|
||||
if (!this._loaded || this._docs.length === 0) return nothing;
|
||||
const L = this._lang;
|
||||
const linked = this._linked();
|
||||
const available = this._available();
|
||||
return html`
|
||||
<div class="task-docs">
|
||||
<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-list">${linked.map((d) => this._renderRow(d, L))}</div>`}
|
||||
${this.canWrite && available.length
|
||||
? html`<div class="tdoc-attach">
|
||||
<select
|
||||
class="tdoc-select"
|
||||
?disabled=${this._busy}
|
||||
@change=${(e: Event) => (this._attachId = (e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
<option value="" ?selected=${!this._attachId}>${t("doc_link_existing", L)}</option>
|
||||
${available.map(
|
||||
(d) => html`<option value=${d.id} ?selected=${d.id === this._attachId}>${d.title || d.filename || d.url}</option>`,
|
||||
)}
|
||||
</select>
|
||||
<button class="tdoc-btn" ?disabled=${this._busy || !this._attachId} @click=${this._link}>
|
||||
<ha-icon icon="mdi:link-variant-plus"></ha-icon> ${t("doc_attach", L)}
|
||||
</button>
|
||||
</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderRow(doc: Doc, L: string) {
|
||||
const isFile = doc.kind === "file";
|
||||
const isPdf = this._isPdf(doc);
|
||||
const page = doc.task_pages?.[this.taskId];
|
||||
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`
|
||||
<div class="tdoc-row">
|
||||
<ha-icon class="tdoc-icon" icon=${isFile ? CATEGORY_ICONS[cat] : "mdi:link-variant"}></ha-icon>
|
||||
<div
|
||||
class="tdoc-info"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
title=${page ? `${t("doc_open", L)} · ${t("doc_page", L)} ${page}` : t("doc_open", L)}
|
||||
@click=${() => this._open(doc)}
|
||||
@keydown=${(e: KeyboardEvent) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
void this._open(doc);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="tdoc-title">${doc.title || doc.filename || doc.url}</div>
|
||||
<div class="tdoc-meta">
|
||||
${meta}${page ? html` · <span class="tdoc-pagetag">${t("doc_page", L)} ${page}</span>` : nothing}
|
||||
</div>
|
||||
</div>
|
||||
${this.canWrite && isPdf
|
||||
? html`<input
|
||||
class="tdoc-page"
|
||||
type="number"
|
||||
min="1"
|
||||
inputmode="numeric"
|
||||
aria-label=${t("doc_page", L)}
|
||||
title=${t("doc_page", L)}
|
||||
placeholder=${t("doc_page", L)}
|
||||
.value=${page ? String(page) : ""}
|
||||
?disabled=${this._busy}
|
||||
@change=${(e: Event) => {
|
||||
const v = parseInt((e.target as HTMLInputElement).value, 10);
|
||||
void this._setPage(doc, Number.isFinite(v) && v >= 1 ? v : 0);
|
||||
}}
|
||||
/>`
|
||||
: nothing}
|
||||
<button class="icon-btn" title=${t("doc_open", L)} @click=${() => this._open(doc)}>
|
||||
<ha-icon icon=${isFile ? "mdi:eye-outline" : "mdi:open-in-new"}></ha-icon>
|
||||
</button>
|
||||
${isFile
|
||||
? html`<button class="icon-btn" title=${t("doc_download", L)} @click=${() => this._download(doc)}>
|
||||
<ha-icon icon="mdi:download"></ha-icon>
|
||||
</button>`
|
||||
: nothing}
|
||||
${this.canWrite
|
||||
? html`<button class="icon-btn" title=${t("doc_unlink", L)} ?disabled=${this._busy} @click=${() => this._unlink(doc)}>
|
||||
<ha-icon icon="mdi:link-variant-off"></ha-icon>
|
||||
</button>`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host { display: block; }
|
||||
.task-docs { margin-top: 20px; }
|
||||
h3 {
|
||||
display: flex; align-items: center; gap: 6px; margin: 0 0 8px;
|
||||
font-size: 15px; color: var(--primary-text-color);
|
||||
}
|
||||
h3 ha-icon { --mdc-icon-size: 18px; color: var(--secondary-text-color, #888); }
|
||||
.tdoc-empty { color: var(--secondary-text-color, #888); font-size: 13px; padding: 2px 0 8px; }
|
||||
.tdoc-error { color: var(--error-color, #f44336); font-size: 13px; margin: 4px 0; }
|
||||
.tdoc-list { display: flex; flex-direction: column; gap: 4px; }
|
||||
.tdoc-row {
|
||||
display: flex; align-items: center; gap: 10px; padding: 6px 10px;
|
||||
border: 1px solid var(--divider-color); border-radius: 8px;
|
||||
}
|
||||
.tdoc-icon { color: var(--primary-color); --mdc-icon-size: 22px; flex: none; }
|
||||
.tdoc-info { flex: 1; min-width: 0; cursor: pointer; border-radius: 6px; }
|
||||
.tdoc-info:hover .tdoc-title { text-decoration: underline; }
|
||||
.tdoc-info:focus-visible { outline: 2px solid var(--primary-color); outline-offset: 2px; }
|
||||
.tdoc-title { font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.tdoc-meta { font-size: 12px; color: var(--secondary-text-color, #888); }
|
||||
.tdoc-pagetag { color: var(--primary-color); font-weight: 500; }
|
||||
.tdoc-page {
|
||||
flex: none; width: 76px; padding: 5px 8px; border-radius: 6px; font: inherit; font-size: 13px;
|
||||
background: var(--secondary-background-color, rgba(0, 0, 0, 0.06));
|
||||
color: var(--primary-text-color); border: 1px solid var(--divider-color);
|
||||
}
|
||||
.tdoc-page:disabled { opacity: 0.5; }
|
||||
.icon-btn {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 34px; height: 34px; border-radius: 8px; cursor: pointer;
|
||||
background: transparent; border: none; color: var(--primary-text-color);
|
||||
}
|
||||
.icon-btn:hover { background: var(--secondary-background-color, rgba(0, 0, 0, 0.06)); }
|
||||
.icon-btn[disabled] { opacity: 0.4; pointer-events: none; }
|
||||
.icon-btn ha-icon { --mdc-icon-size: 20px; }
|
||||
.tdoc-attach { display: flex; gap: 8px; margin-top: 8px; flex-wrap: wrap; }
|
||||
.tdoc-select {
|
||||
flex: 1; min-width: 160px; padding: 6px 10px; border-radius: 6px; font: inherit;
|
||||
background: var(--secondary-background-color, rgba(0, 0, 0, 0.06));
|
||||
color: var(--primary-text-color); border: 1px solid var(--divider-color);
|
||||
}
|
||||
.tdoc-btn {
|
||||
display: inline-flex; align-items: center; gap: 6px; cursor: pointer;
|
||||
padding: 6px 12px; border-radius: 6px; font: inherit; font-size: 13px;
|
||||
background: var(--secondary-background-color, rgba(0, 0, 0, 0.06));
|
||||
color: var(--primary-text-color); border: 1px solid var(--divider-color);
|
||||
}
|
||||
.tdoc-btn ha-icon { --mdc-icon-size: 18px; }
|
||||
.tdoc-btn[disabled] { opacity: 0.5; pointer-events: none; }
|
||||
`;
|
||||
}
|
||||
|
||||
if (!customElements.get("maintenance-task-documents")) {
|
||||
customElements.define("maintenance-task-documents", MaintenanceTaskDocuments);
|
||||
}
|
||||
+815
@@ -0,0 +1,815 @@
|
||||
/** v2.3.0 — Task Quick-Actions Dialog.
|
||||
*
|
||||
* Opens from the Lovelace card / strategy when the user clicks a task row.
|
||||
* Surfaces every action the panel's Task-Detail header offers, in-place,
|
||||
* without forcing a panel-roundtrip:
|
||||
*
|
||||
* • Quick info — name + status + next due + last performed + interval
|
||||
* • Primary actions — Complete (existing dialog), Skip (inline), Reset (inline)
|
||||
* • Secondary (admin) — Edit settings (existing dialog), QR Code, Delete
|
||||
* • Footer — "Open in Maintenance Panel" deep-link for History/Statistics
|
||||
*
|
||||
* Mounted via dialog-mount.openTaskQuickActions(entryId, taskId).
|
||||
*/
|
||||
|
||||
import { LitElement, html, css, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { sharedStyles, t, STATUS_COLORS, formatDate, formatDateTime, formatRecurrence } from "../styles";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { renderWeibullSection } from "../renderers/weibull";
|
||||
import { renderPredictionSection } from "../renderers/prediction";
|
||||
import { renderRecommendationBars } from "../renderers/recommendation";
|
||||
import {
|
||||
renderSeasonalCardCompact,
|
||||
renderSeasonalCardExpanded,
|
||||
} from "../renderers/seasonal";
|
||||
import type {
|
||||
HomeAssistant,
|
||||
HistoryEntry,
|
||||
MaintenanceObjectResponse,
|
||||
MaintenanceTask,
|
||||
AdvancedFeatures,
|
||||
} from "../types";
|
||||
|
||||
interface MaintenanceObjectFull {
|
||||
entry_id: string;
|
||||
object: { id: string; name: string };
|
||||
tasks: MaintenanceTask[];
|
||||
}
|
||||
|
||||
export class MaintenanceTaskQuickActionsDialog extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _open = false;
|
||||
@state() private _entryId: string | null = null;
|
||||
@state() private _taskId: string | null = null;
|
||||
@state() private _task: MaintenanceTask | null = null;
|
||||
@state() private _objectName = "";
|
||||
@state() private _busy = false;
|
||||
@state() private _error = "";
|
||||
@state() private _showSkip = false;
|
||||
@state() private _showReset = false;
|
||||
@state() private _showDetails = false;
|
||||
@state() private _showAdaptive = false;
|
||||
@state() private _skipReason = "";
|
||||
@state() private _resetDate = "";
|
||||
@state() private _features: AdvancedFeatures = {
|
||||
adaptive: false, predictions: false, seasonal: false, environmental: false,
|
||||
budget: false, groups: false, checklists: false, schedule_time: false,
|
||||
completion_actions: false,
|
||||
};
|
||||
@state() private _toast = "";
|
||||
private _featuresLoaded = false;
|
||||
|
||||
private get _lang(): string {
|
||||
return this.hass?.language || "en";
|
||||
}
|
||||
|
||||
/** Open the dialog. Loads fresh data from /object via WS so dialog stays in
|
||||
* sync even if the underlying card has stale data. */
|
||||
public async openFor(entryId: string, taskId: string): Promise<void> {
|
||||
this._entryId = entryId;
|
||||
this._taskId = taskId;
|
||||
this._error = "";
|
||||
this._showSkip = false;
|
||||
this._showReset = false;
|
||||
this._showAdaptive = false;
|
||||
this._skipReason = "";
|
||||
this._resetDate = new Date().toISOString().slice(0, 10);
|
||||
this._open = true;
|
||||
await Promise.all([this._loadTask(), this._loadFeatures()]);
|
||||
}
|
||||
|
||||
/** Pull the active feature flags so adaptive sections only render when
|
||||
* Adaptive / Seasonal / Environmental are actually enabled (matches the
|
||||
* panel's behaviour). Cached after first load. */
|
||||
private async _loadFeatures(): Promise<void> {
|
||||
if (this._featuresLoaded) return;
|
||||
try {
|
||||
const r = await this.hass.connection.sendMessagePromise<{
|
||||
features?: Partial<AdvancedFeatures>;
|
||||
}>({ type: "maintenance_supporter/settings" });
|
||||
if (r?.features) {
|
||||
this._features = { ...this._features, ...r.features };
|
||||
}
|
||||
this._featuresLoaded = true;
|
||||
} catch {
|
||||
// Settings endpoint unavailable — leave defaults (all false), the
|
||||
// adaptive panel will then stay hidden.
|
||||
}
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
this._open = false;
|
||||
this._task = null;
|
||||
this._error = "";
|
||||
}
|
||||
|
||||
private async _loadTask(): Promise<void> {
|
||||
if (!this._entryId || !this._taskId) return;
|
||||
try {
|
||||
const r = await this.hass.connection.sendMessagePromise<MaintenanceObjectFull>({
|
||||
type: "maintenance_supporter/object",
|
||||
entry_id: this._entryId,
|
||||
});
|
||||
this._objectName = r.object?.name || "";
|
||||
const found = (r.tasks || []).find((t) => t.id === this._taskId);
|
||||
this._task = found ?? null;
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
}
|
||||
}
|
||||
|
||||
private async _runWs(payload: Record<string, unknown>): Promise<boolean> {
|
||||
this._busy = true;
|
||||
this._error = "";
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise(payload);
|
||||
this._busy = false;
|
||||
return true;
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
this._busy = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private _notifyChanged(action: string): void {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("task-action-fired", {
|
||||
detail: { entry_id: this._entryId, task_id: this._taskId, action },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private _onComplete(): void {
|
||||
if (!this._entryId || !this._taskId || !this._task) return;
|
||||
// Reuse the existing rich complete-dialog by mounting it on body
|
||||
import("../dialog-mount").then(({ openCompleteDialog }) => {
|
||||
const ok = openCompleteDialog({
|
||||
entry_id: this._entryId!,
|
||||
task_id: this._taskId!,
|
||||
task_name: this._task!.name,
|
||||
checklist: this._task!.checklist || [],
|
||||
adaptive_enabled: !!this._task!.adaptive_config?.enabled,
|
||||
});
|
||||
if (ok) {
|
||||
this._notifyChanged("complete");
|
||||
this.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async _onSkipConfirm(): Promise<void> {
|
||||
if (!this._entryId || !this._taskId) return;
|
||||
const ok = await this._runWs({
|
||||
type: "maintenance_supporter/task/skip",
|
||||
entry_id: this._entryId,
|
||||
task_id: this._taskId,
|
||||
reason: this._skipReason.trim() || null,
|
||||
});
|
||||
if (ok) {
|
||||
this._notifyChanged("skip");
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
private async _onResetConfirm(): Promise<void> {
|
||||
if (!this._entryId || !this._taskId) return;
|
||||
const ok = await this._runWs({
|
||||
type: "maintenance_supporter/task/reset",
|
||||
entry_id: this._entryId,
|
||||
task_id: this._taskId,
|
||||
date: this._resetDate || undefined,
|
||||
});
|
||||
if (ok) {
|
||||
this._notifyChanged("reset");
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
private _onEdit(): void {
|
||||
if (!this._entryId || !this._taskId) return;
|
||||
import("../dialog-mount").then(({ openEditTaskDialog }) => {
|
||||
openEditTaskDialog(this._entryId!, this._taskId!);
|
||||
this.close();
|
||||
});
|
||||
}
|
||||
|
||||
private _onQr(): void {
|
||||
if (!this._entryId || !this._taskId || !this._task) return;
|
||||
import("../dialog-mount").then(({ openQrDialog }) => {
|
||||
openQrDialog({
|
||||
entry_id: this._entryId!,
|
||||
task_id: this._taskId!,
|
||||
task_name: this._task!.name,
|
||||
object_name: this._objectName,
|
||||
});
|
||||
this.close();
|
||||
});
|
||||
}
|
||||
|
||||
private async _onDelete(): Promise<void> {
|
||||
if (!this._entryId || !this._taskId) return;
|
||||
const confirmText = t("delete_task_confirm", this._lang)
|
||||
|| `Delete "${this._task?.name}"?`;
|
||||
if (!window.confirm(confirmText)) return;
|
||||
const ok = await this._runWs({
|
||||
type: "maintenance_supporter/task/delete",
|
||||
entry_id: this._entryId,
|
||||
task_id: this._taskId,
|
||||
});
|
||||
if (ok) {
|
||||
this._notifyChanged("delete");
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
private async _onArchive(): Promise<void> {
|
||||
if (!this._entryId || !this._taskId) return;
|
||||
const ok = await this._runWs({
|
||||
type: "maintenance_supporter/task/archive",
|
||||
entry_id: this._entryId,
|
||||
task_id: this._taskId,
|
||||
});
|
||||
if (ok) {
|
||||
this._notifyChanged("archive");
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
private async _onUnarchive(): Promise<void> {
|
||||
if (!this._entryId || !this._taskId) return;
|
||||
const ok = await this._runWs({
|
||||
type: "maintenance_supporter/task/unarchive",
|
||||
entry_id: this._entryId,
|
||||
task_id: this._taskId,
|
||||
});
|
||||
if (ok) {
|
||||
this._notifyChanged("unarchive");
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
private _onOpenInPanel(): void {
|
||||
if (!this._entryId || !this._taskId) return;
|
||||
const path = `/maintenance-supporter?entry_id=${encodeURIComponent(this._entryId)}`
|
||||
+ `&task_id=${encodeURIComponent(this._taskId)}`;
|
||||
history.pushState(null, "", path);
|
||||
window.dispatchEvent(new CustomEvent("location-changed"));
|
||||
this.close();
|
||||
}
|
||||
|
||||
private async _applySuggestion(): Promise<void> {
|
||||
if (!this._entryId || !this._taskId || !this._task?.suggested_interval) return;
|
||||
const ok = await this._runWs({
|
||||
type: "maintenance_supporter/task/apply_suggestion",
|
||||
entry_id: this._entryId,
|
||||
task_id: this._taskId,
|
||||
interval: this._task.suggested_interval,
|
||||
});
|
||||
if (ok) {
|
||||
this._toast = t("suggestion_applied", this._lang) || "Applied";
|
||||
this._notifyChanged("apply_suggestion");
|
||||
// Refresh local task so the recommendation card hides
|
||||
await this._loadTask();
|
||||
setTimeout(() => { this._toast = ""; }, 2500);
|
||||
}
|
||||
}
|
||||
|
||||
private async _reanalyzeInterval(): Promise<void> {
|
||||
if (!this._entryId || !this._taskId) return;
|
||||
this._busy = true;
|
||||
this._error = "";
|
||||
try {
|
||||
const r = await this.hass.connection.sendMessagePromise<{
|
||||
recommended_interval: number | null;
|
||||
confidence: string;
|
||||
data_points: number;
|
||||
}>({
|
||||
type: "maintenance_supporter/task/analyze_interval",
|
||||
entry_id: this._entryId,
|
||||
task_id: this._taskId,
|
||||
});
|
||||
this._toast = r.recommended_interval
|
||||
? `${t("reanalyze_result", this._lang) || "Recomputed"}: ${r.recommended_interval}d (${r.data_points} pts)`
|
||||
: (t("reanalyze_insufficient_data", this._lang) || "Not enough data");
|
||||
await this._loadTask();
|
||||
setTimeout(() => { this._toast = ""; }, 3500);
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _onEditHistoryEntry(entry: HistoryEntry): void {
|
||||
if (!this._entryId || !this._taskId) return;
|
||||
import("../dialog-mount").then(({ openHistoryEditDialog }) => {
|
||||
openHistoryEditDialog({
|
||||
entry_id: this._entryId!,
|
||||
task_id: this._taskId!,
|
||||
original_timestamp: entry.timestamp,
|
||||
type: entry.type,
|
||||
timestamp: entry.timestamp,
|
||||
notes: entry.notes ?? null,
|
||||
cost: entry.cost ?? null,
|
||||
duration: entry.duration ?? null,
|
||||
completed_by: entry.completed_by ?? null,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Inline recommendation card (Current vs Suggested with apply/reanalyze).
|
||||
* Bars + confidence badge come from the shared renderer; the action row
|
||||
* uses native <button>s because <ha-button> isn't always registered in
|
||||
* the Lovelace context (same lazy-load issue that bit complete-dialog
|
||||
* with ha-textfield in #50). The panel uses ha-button + adds Dismiss. */
|
||||
private _renderRecommendation(task: MaintenanceTask) {
|
||||
if (!this._features.adaptive
|
||||
|| !task.suggested_interval
|
||||
|| task.suggested_interval === task.interval_days) {
|
||||
return nothing;
|
||||
}
|
||||
const L = this._lang;
|
||||
return html`
|
||||
<div class="recommendation-card">
|
||||
<h4>${t("suggested_interval", L)}</h4>
|
||||
${renderRecommendationBars(
|
||||
task.interval_days, task.suggested_interval,
|
||||
task.interval_confidence || "medium", L,
|
||||
)}
|
||||
<div class="recommendation-actions">
|
||||
<button class="btn primary"
|
||||
@click=${this._applySuggestion} ?disabled=${this._busy}>
|
||||
<ha-icon icon="mdi:check"></ha-icon>
|
||||
${t("apply_suggestion", L)}
|
||||
</button>
|
||||
<button class="btn"
|
||||
@click=${this._reanalyzeInterval} ?disabled=${this._busy}>
|
||||
<ha-icon icon="mdi:refresh"></ha-icon>
|
||||
${t("reanalyze", L)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/** Adaptive section: prediction + recommendation + Weibull + seasonal,
|
||||
* reusing the panel's renderers. Only renders blocks that have data. */
|
||||
private _renderAdaptive(task: MaintenanceTask) {
|
||||
const L = this._lang;
|
||||
const hasRecommendation = this._features.adaptive
|
||||
&& task.suggested_interval
|
||||
&& task.suggested_interval !== task.interval_days;
|
||||
const hasPrediction = (task.degradation_trend != null
|
||||
&& task.degradation_trend !== "insufficient_data")
|
||||
|| task.days_until_threshold != null
|
||||
|| (task.environmental_factor != null && task.environmental_factor !== 1.0);
|
||||
const hasWeibull = this._features.adaptive
|
||||
&& task.interval_analysis?.weibull_beta != null
|
||||
&& task.interval_analysis?.weibull_eta != null;
|
||||
const hasSeasonal = this._features.seasonal
|
||||
&& task.seasonal_factor
|
||||
&& task.seasonal_factor !== 1.0;
|
||||
|
||||
if (!hasRecommendation && !hasPrediction && !hasWeibull && !hasSeasonal) {
|
||||
return html`<div class="adaptive-empty">
|
||||
${t("adaptive_no_data", L) || "Not enough completion history yet for adaptive analysis."}
|
||||
</div>`;
|
||||
}
|
||||
return html`
|
||||
<div class="adaptive-stack">
|
||||
${this._toast
|
||||
? html`<div class="toast">${this._toast}</div>`
|
||||
: nothing}
|
||||
${hasRecommendation ? this._renderRecommendation(task) : nothing}
|
||||
${hasPrediction ? renderPredictionSection(task, L, this._features) : nothing}
|
||||
${hasWeibull ? renderWeibullSection(task, L) : nothing}
|
||||
${hasSeasonal ? html`
|
||||
${renderSeasonalCardCompact(task, L, this._features)}
|
||||
${task.seasonal_factors?.length === 12
|
||||
|| task.interval_analysis?.seasonal_factors?.length === 12
|
||||
? renderSeasonalCardExpanded(task, L)
|
||||
: nothing}
|
||||
` : nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/** Read-only details panel: stats + history. Shown when the user clicks
|
||||
* "Show details" in the dialog. Edit-buttons on history entries open the
|
||||
* existing history-edit dialog (which lives in the same dialog-mount). */
|
||||
private _renderDetails(task: MaintenanceTask) {
|
||||
const L = this._lang;
|
||||
const history = (task.history || []) as HistoryEntry[];
|
||||
const completed = history.filter((h) => h.type === "completed");
|
||||
const totalCost = completed.reduce(
|
||||
(s, h) => s + (typeof h.cost === "number" ? h.cost : 0),
|
||||
0,
|
||||
);
|
||||
const avgDuration = (() => {
|
||||
const durs = completed
|
||||
.map((h) => (typeof h.duration === "number" ? h.duration : null))
|
||||
.filter((d): d is number => d != null);
|
||||
if (!durs.length) return null;
|
||||
return Math.round(durs.reduce((s, d) => s + d, 0) / durs.length);
|
||||
})();
|
||||
|
||||
return html`
|
||||
<div class="details">
|
||||
<div class="stats-grid">
|
||||
<div class="stat">
|
||||
<span class="stat-label">${t("times_performed", L) || "Performed"}</span>
|
||||
<span class="stat-value">${completed.length}</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">${t("total_cost", L) || "Total cost"}</span>
|
||||
<span class="stat-value">${totalCost.toFixed(2)}</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">${t("avg_duration", L) || "Avg duration"}</span>
|
||||
<span class="stat-value">${avgDuration != null ? `${avgDuration}m` : "—"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="history-header">
|
||||
<strong>${t("history", L) || "History"}</strong>
|
||||
<span class="history-count">${history.length}</span>
|
||||
</div>
|
||||
${history.length === 0
|
||||
? html`<div class="history-empty">${t("history_empty", L) || "No history yet."}</div>`
|
||||
: html`
|
||||
<div class="history-list">
|
||||
${[...history].reverse().slice(0, 20).map((entry) => {
|
||||
const editable = ["completed", "reset", "skipped"].includes(entry.type);
|
||||
return html`
|
||||
<div class="history-entry">
|
||||
<div class="history-line">
|
||||
<span class="history-type type-${entry.type}">${t(entry.type, L)}</span>
|
||||
<span class="history-date">${formatDateTime(entry.timestamp, L)}</span>
|
||||
${editable
|
||||
? html`<button class="history-edit"
|
||||
title="${t("history_edit_button", L) || "Edit"}"
|
||||
@click=${() => this._onEditHistoryEntry(entry)}>
|
||||
<ha-icon icon="mdi:pencil"></ha-icon>
|
||||
</button>`
|
||||
: nothing}
|
||||
</div>
|
||||
${entry.notes
|
||||
? html`<div class="history-notes">${entry.notes}</div>`
|
||||
: nothing}
|
||||
${entry.cost != null || entry.duration != null
|
||||
? html`<div class="history-meta">
|
||||
${entry.cost != null ? html`<span>💰 ${entry.cost.toFixed(2)}</span>` : nothing}
|
||||
${entry.duration != null ? html`<span>⏱️ ${entry.duration}m</span>` : nothing}
|
||||
</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
${history.length > 20
|
||||
? html`<div class="history-more">… +${history.length - 20} ${t("older_entries", L) || "older"}</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this._open) return nothing;
|
||||
const L = this._lang;
|
||||
const task = this._task;
|
||||
const isAdmin = (this.hass?.user?.is_admin ?? true) as boolean;
|
||||
|
||||
return html`
|
||||
<div class="backdrop" @click=${this.close}></div>
|
||||
<div class="dialog" role="dialog" aria-modal="true">
|
||||
${task
|
||||
? html`
|
||||
<div class="header">
|
||||
<div class="title">
|
||||
<span class="status-dot" style="background: ${STATUS_COLORS[task.status] || "#ccc"}"></span>
|
||||
<span class="task-name">${task.name}</span>
|
||||
</div>
|
||||
<div class="object">
|
||||
<button class="link-inline" @click=${() => {
|
||||
if (!this._entryId) return;
|
||||
import("../dialog-mount").then(({ openObjectQuickActions }) => {
|
||||
openObjectQuickActions(this._entryId!);
|
||||
this.close();
|
||||
});
|
||||
}}>${this._objectName}</button>
|
||||
</div>
|
||||
<div class="quick-info">
|
||||
${task.next_due
|
||||
? html`<span><strong>${t("next_due", L) || "Next due"}:</strong> ${formatDate(task.next_due, L)}</span>`
|
||||
: nothing}
|
||||
${task.last_performed
|
||||
? html`<span><strong>${t("last_performed", L) || "Last"}:</strong> ${formatDate(task.last_performed, L)}</span>`
|
||||
: nothing}
|
||||
${(task.schedule?.kind && !["manual", "one_time"].includes(task.schedule.kind)) || task.interval_days != null
|
||||
? html`<span><strong>${t("interval", L) || "Interval"}:</strong> ${formatRecurrence(task, L)}</span>`
|
||||
: nothing}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${this._error
|
||||
? html`<div class="error">${this._error}</div>`
|
||||
: nothing}
|
||||
|
||||
${this._showSkip
|
||||
? html`
|
||||
<div class="inline-form">
|
||||
<label>${t("skip_reason", L) || "Skip reason (optional)"}</label>
|
||||
<input type="text" .value=${this._skipReason}
|
||||
@input=${(e: Event) => { this._skipReason = (e.target as HTMLInputElement).value; }} />
|
||||
<div class="inline-actions">
|
||||
<button class="btn cancel" @click=${() => { this._showSkip = false; }} ?disabled=${this._busy}>
|
||||
${t("cancel", L) || "Cancel"}
|
||||
</button>
|
||||
<button class="btn primary" @click=${this._onSkipConfirm} ?disabled=${this._busy}>
|
||||
${t("skip", L) || "Skip"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: this._showReset
|
||||
? html`
|
||||
<div class="inline-form">
|
||||
<label>${t("reset_to_date", L) || "Reset last_performed to"}</label>
|
||||
<input type="date" .value=${this._resetDate}
|
||||
@input=${(e: Event) => { this._resetDate = (e.target as HTMLInputElement).value; }} />
|
||||
<div class="inline-actions">
|
||||
<button class="btn cancel" @click=${() => { this._showReset = false; }} ?disabled=${this._busy}>
|
||||
${t("cancel", L) || "Cancel"}
|
||||
</button>
|
||||
<button class="btn primary" @click=${this._onResetConfirm} ?disabled=${this._busy}>
|
||||
${t("reset", L) || "Reset"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<div class="actions primary-row">
|
||||
<button class="btn primary" @click=${this._onComplete} ?disabled=${this._busy}>
|
||||
<ha-icon icon="mdi:check"></ha-icon>
|
||||
${t("complete", L) || "Complete"}
|
||||
</button>
|
||||
<button class="btn" @click=${() => { this._showSkip = true; }} ?disabled=${this._busy}>
|
||||
<ha-icon icon="mdi:skip-next"></ha-icon>
|
||||
${t("skip", L) || "Skip"}
|
||||
</button>
|
||||
<button class="btn" @click=${() => { this._showReset = true; }} ?disabled=${this._busy}>
|
||||
<ha-icon icon="mdi:restart"></ha-icon>
|
||||
${t("reset", L) || "Reset"}
|
||||
</button>
|
||||
</div>
|
||||
${isAdmin
|
||||
? html`
|
||||
<div class="actions secondary-row">
|
||||
<button class="btn ghost" @click=${this._onEdit} ?disabled=${this._busy}>
|
||||
<ha-icon icon="mdi:pencil"></ha-icon>
|
||||
${t("edit", L) || "Edit"}
|
||||
</button>
|
||||
<button class="btn ghost" @click=${this._onQr} ?disabled=${this._busy}>
|
||||
<ha-icon icon="mdi:qrcode"></ha-icon>
|
||||
${t("qr_code", L) || "QR"}
|
||||
</button>
|
||||
<button class="btn ghost"
|
||||
@click=${task.archived ? this._onUnarchive : this._onArchive}
|
||||
?disabled=${this._busy}>
|
||||
<ha-icon icon="${task.archived ? 'mdi:archive-arrow-up-outline' : 'mdi:archive-outline'}"></ha-icon>
|
||||
${task.archived ? (t("unarchive", L) || "Unarchive") : (t("archive", L) || "Archive")}
|
||||
</button>
|
||||
<button class="btn ghost danger" @click=${this._onDelete} ?disabled=${this._busy}>
|
||||
<ha-icon icon="mdi:delete"></ha-icon>
|
||||
${t("delete", L) || "Delete"}
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
<div class="details-toggle">
|
||||
<button class="link" @click=${() => { this._showDetails = !this._showDetails; }}>
|
||||
<ha-icon icon="${this._showDetails ? 'mdi:chevron-up' : 'mdi:chevron-down'}"></ha-icon>
|
||||
${this._showDetails
|
||||
? (t("hide_details", L) || "Hide details")
|
||||
: (t("show_details", L) || "Show history + stats")}
|
||||
</button>
|
||||
${this._features.adaptive
|
||||
|| this._features.seasonal
|
||||
|| this._features.environmental
|
||||
? html`<button class="link" @click=${() => { this._showAdaptive = !this._showAdaptive; }}>
|
||||
<ha-icon icon="${this._showAdaptive ? 'mdi:chart-line' : 'mdi:chart-line-variant'}"></ha-icon>
|
||||
${this._showAdaptive
|
||||
? (t("hide_stats", L) || "Hide stats")
|
||||
: (t("show_stats", L) || "Show stats + graphs")}
|
||||
</button>`
|
||||
: nothing}
|
||||
</div>
|
||||
${this._showDetails ? this._renderDetails(task) : nothing}
|
||||
${this._showAdaptive ? this._renderAdaptive(task) : nothing}
|
||||
<div class="footer">
|
||||
<button class="link" @click=${this._onOpenInPanel}>
|
||||
<ha-icon icon="mdi:open-in-new"></ha-icon>
|
||||
${t("open_in_panel", L) || "Open in Maintenance panel"}
|
||||
</button>
|
||||
</div>
|
||||
`}
|
||||
`
|
||||
: html`<div class="loading">${t("loading", L) || "Loading…"}</div>`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = [sharedStyles, css`
|
||||
:host { display: contents; }
|
||||
.backdrop {
|
||||
position: fixed; inset: 0; z-index: 100;
|
||||
background: rgba(0,0,0,0.5);
|
||||
}
|
||||
.dialog {
|
||||
position: fixed; left: 50%; top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 95vw; max-width: 460px;
|
||||
max-height: 92vh; overflow: auto;
|
||||
background: var(--card-background-color, var(--ha-card-background, #1c1c1c));
|
||||
color: var(--primary-text-color);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.4);
|
||||
padding: 20px;
|
||||
display: flex; flex-direction: column; gap: 14px;
|
||||
z-index: 101;
|
||||
}
|
||||
.header { display: flex; flex-direction: column; gap: 6px; }
|
||||
.title { display: flex; align-items: center; gap: 10px; }
|
||||
.status-dot { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }
|
||||
.task-name { font-size: 18px; font-weight: 600; }
|
||||
.object { font-size: 13px; color: var(--secondary-text-color); }
|
||||
.link-inline {
|
||||
background: transparent; border: none; padding: 0; cursor: pointer;
|
||||
color: var(--primary-color); font-size: inherit; font-family: inherit;
|
||||
}
|
||||
.link-inline:hover { text-decoration: underline; }
|
||||
.quick-info {
|
||||
display: flex; flex-wrap: wrap; gap: 12px;
|
||||
font-size: 12px; color: var(--secondary-text-color);
|
||||
padding-top: 4px; border-top: 1px solid var(--divider-color);
|
||||
}
|
||||
.quick-info strong { color: var(--primary-text-color); font-weight: 500; }
|
||||
.actions { display: flex; gap: 8px; }
|
||||
.actions.primary-row { gap: 6px; }
|
||||
.actions.primary-row .btn { flex: 1; }
|
||||
/* Edit + QR are admin-tools — left-align as a group; Delete is destructive
|
||||
so it gets pushed to the far right with margin-left:auto for visual
|
||||
separation. Earlier this row was flex-end which left a strange empty
|
||||
gap on the left (user feedback). */
|
||||
.actions.secondary-row {
|
||||
padding-top: 8px; border-top: 1px solid var(--divider-color);
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.actions.secondary-row .btn.danger {
|
||||
margin-left: auto;
|
||||
}
|
||||
.btn {
|
||||
padding: 8px 12px; font-size: 14px;
|
||||
border-radius: 6px; cursor: pointer;
|
||||
border: 1px solid var(--divider-color);
|
||||
background: var(--secondary-background-color, transparent);
|
||||
color: var(--primary-text-color);
|
||||
font-weight: 500;
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.btn:hover { background: var(--state-icon-color, rgba(255,255,255,0.06)); }
|
||||
.btn[disabled] { opacity: 0.5; cursor: wait; }
|
||||
.btn.primary {
|
||||
background: var(--primary-color);
|
||||
color: var(--text-primary-color, white);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
.btn.cancel { background: transparent; }
|
||||
.btn.ghost { padding: 6px 10px; font-size: 13px; }
|
||||
.btn.danger { color: var(--error-color); }
|
||||
.btn ha-icon { --mdc-icon-size: 18px; }
|
||||
.inline-form { display: flex; flex-direction: column; gap: 8px; }
|
||||
.inline-form label { font-size: 13px; color: var(--secondary-text-color); }
|
||||
.inline-form input {
|
||||
padding: 8px; font-size: 14px;
|
||||
background: var(--secondary-background-color, #2c2c2c);
|
||||
color: var(--primary-text-color);
|
||||
border: 1px solid var(--divider-color, #444);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.inline-actions { display: flex; gap: 8px; justify-content: flex-end; }
|
||||
.footer { display: flex; justify-content: center; padding-top: 4px; }
|
||||
.link {
|
||||
background: transparent; border: none; cursor: pointer;
|
||||
color: var(--primary-color); font-size: 13px;
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
}
|
||||
.link:hover { text-decoration: underline; }
|
||||
.link ha-icon { --mdc-icon-size: 14px; }
|
||||
.loading { padding: 24px; text-align: center; color: var(--secondary-text-color); }
|
||||
.error {
|
||||
padding: 8px; border-radius: 6px;
|
||||
background: rgba(211,47,47,0.1);
|
||||
color: var(--error-color, #d32f2f); font-size: 13px;
|
||||
}
|
||||
|
||||
/* Details (expandable Show details section) */
|
||||
.details-toggle { display: flex; justify-content: center; margin-top: 4px; }
|
||||
.details {
|
||||
display: flex; flex-direction: column; gap: 12px;
|
||||
border-top: 1px solid var(--divider-color);
|
||||
padding-top: 12px;
|
||||
}
|
||||
.stats-grid {
|
||||
display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px;
|
||||
}
|
||||
.stat {
|
||||
display: flex; flex-direction: column; gap: 2px;
|
||||
background: var(--secondary-background-color, rgba(255,255,255,0.04));
|
||||
padding: 8px; border-radius: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.stat-label { font-size: 11px; color: var(--secondary-text-color); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.stat-value { font-size: 16px; font-weight: 600; }
|
||||
.history-header {
|
||||
display: flex; align-items: baseline; gap: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.history-count {
|
||||
font-size: 12px; color: var(--secondary-text-color);
|
||||
background: var(--secondary-background-color); padding: 2px 8px; border-radius: 999px;
|
||||
}
|
||||
.history-empty { color: var(--secondary-text-color); font-style: italic; font-size: 13px; }
|
||||
.history-list { display: flex; flex-direction: column; gap: 8px; max-height: 280px; overflow: auto; }
|
||||
.history-entry {
|
||||
padding: 6px 8px; border-radius: 6px;
|
||||
background: var(--secondary-background-color, rgba(255,255,255,0.03));
|
||||
font-size: 13px;
|
||||
}
|
||||
.history-line {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.history-type {
|
||||
font-weight: 600; font-size: 11px;
|
||||
padding: 2px 6px; border-radius: 4px;
|
||||
text-transform: uppercase; letter-spacing: 0.5px;
|
||||
}
|
||||
.type-completed { background: rgba(46,125,50,0.2); color: #66bb6a; }
|
||||
.type-skipped { background: rgba(158,158,158,0.2); color: var(--secondary-text-color); }
|
||||
.type-reset { background: rgba(33,150,243,0.2); color: #64b5f6; }
|
||||
.type-triggered { background: rgba(255,87,34,0.2); color: #ff8a65; }
|
||||
.history-date { font-size: 11px; color: var(--secondary-text-color); flex: 1; text-align: right; }
|
||||
.history-edit {
|
||||
background: transparent; border: none; cursor: pointer;
|
||||
padding: 4px; border-radius: 4px;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.history-edit:hover { background: var(--state-icon-color, rgba(255,255,255,0.06)); color: var(--primary-color); }
|
||||
.history-edit ha-icon { --mdc-icon-size: 14px; }
|
||||
.history-notes { margin-top: 4px; color: var(--primary-text-color); }
|
||||
.history-meta { display: flex; gap: 12px; margin-top: 4px; color: var(--secondary-text-color); font-size: 11px; }
|
||||
.history-more { padding: 8px; text-align: center; font-size: 12px; color: var(--secondary-text-color); font-style: italic; }
|
||||
|
||||
/* Adaptive section — wraps the panel renderers (which assume sharedStyles
|
||||
are present) and adds dialog-specific layout. */
|
||||
.adaptive-stack {
|
||||
display: flex; flex-direction: column; gap: 12px;
|
||||
border-top: 1px solid var(--divider-color);
|
||||
padding-top: 12px;
|
||||
}
|
||||
.adaptive-empty {
|
||||
padding: 16px; text-align: center;
|
||||
color: var(--secondary-text-color);
|
||||
font-style: italic; font-size: 13px;
|
||||
border-top: 1px solid var(--divider-color);
|
||||
}
|
||||
.toast {
|
||||
padding: 8px 12px; border-radius: 6px;
|
||||
background: rgba(76, 175, 80, 0.15);
|
||||
color: #4caf50; font-size: 13px; font-weight: 500;
|
||||
}
|
||||
/* The panel's recommendation-card uses ha-button. We use plain <button>
|
||||
in this dialog's button styles. Re-style the action row to match. */
|
||||
.recommendation-actions {
|
||||
display: flex; gap: 8px; margin-top: 8px;
|
||||
}
|
||||
/* Constrain SVG charts so they fit the dialog width even on mobile. */
|
||||
.weibull-section, .seasonal-card-compact { max-width: 100%; }
|
||||
.weibull-chart svg { max-width: 100%; height: auto; }
|
||||
.details-toggle { gap: 12px; flex-wrap: wrap; }
|
||||
`];
|
||||
}
|
||||
|
||||
if (!customElements.get("maintenance-task-quick-actions-dialog")) {
|
||||
customElements.define(
|
||||
"maintenance-task-quick-actions-dialog",
|
||||
MaintenanceTaskQuickActionsDialog,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
/** Responsive sensor-history chart for the task detail view.
|
||||
*
|
||||
* Replaces the fixed 300x140 sparkline: renders at the card's real pixel
|
||||
* width (ResizeObserver), draws round y-ticks with gridlines, shades the
|
||||
* danger zone beyond a threshold (with the in-zone line segments in the
|
||||
* error color via clip paths), pins a target line for counters, moves
|
||||
* completion markers into a calm bottom lane, and offers a crosshair that
|
||||
* follows the pointer (works for touch) plus 7d/30d/90d/1y range chips.
|
||||
*
|
||||
* The component is deliberately dumb: it plots the points and reference
|
||||
* lines it is given. All task semantics (delta baselines, cumulative
|
||||
* clamping, projections) live in renderers/sparkline.ts.
|
||||
*/
|
||||
|
||||
import { LitElement, html, css, svg, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { t } from "../styles";
|
||||
import { niceTicks, fmtNum, fmtVal, fmtDateTick, fmtDateTime, timeTicks, needsYear } from "../renderers/chart-utils";
|
||||
|
||||
export interface ChartPoint {
|
||||
ts: number;
|
||||
val: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
export interface ChartEvent {
|
||||
ts: number;
|
||||
type: string; // completed | skipped | reset
|
||||
}
|
||||
|
||||
const H = 210;
|
||||
const PAD_L = 46;
|
||||
const PAD_R = 14;
|
||||
const PAD_T = 12;
|
||||
const LANE_H = 14; // completion-marker lane between plot and x labels
|
||||
const PAD_B = 20 + LANE_H;
|
||||
|
||||
const RANGES = [
|
||||
{ days: 7, key: "chart_range_7d" },
|
||||
{ days: 30, key: "chart_range_30d" },
|
||||
{ days: 90, key: "chart_range_90d" },
|
||||
{ days: 365, key: "chart_range_1y" },
|
||||
] as const;
|
||||
|
||||
export class MaintenanceTriggerChart extends LitElement {
|
||||
@property({ attribute: false }) public points: ChartPoint[] = [];
|
||||
@property({ attribute: false }) public events: ChartEvent[] = [];
|
||||
@property() public unit = "";
|
||||
@property() public lang = "en";
|
||||
@property({ attribute: false }) public thresholdAbove: number | null = null;
|
||||
@property({ attribute: false }) public thresholdBelow: number | null = null;
|
||||
/** Absolute y of a counter target line (points are pre-transformed). */
|
||||
@property({ attribute: false }) public targetValue: number | null = null;
|
||||
@property({ type: Boolean }) public forceZero = false;
|
||||
/** Optional dashed projection segment (e.g. degradation trend). */
|
||||
@property({ attribute: false }) public projection: ChartPoint[] | null = null;
|
||||
@property({ attribute: false }) public rangeDays = 30;
|
||||
@property({ type: Boolean }) public showRange = true;
|
||||
@property({ type: Boolean }) public busy = false;
|
||||
/** Reflects the parent's "hide outliers" state (filtering happens upstream). */
|
||||
@property({ type: Boolean }) public hideOutliers = false;
|
||||
/** Show the outlier toggle chip (only where a raw oscillating series exists). */
|
||||
@property({ type: Boolean }) public showOutlierToggle = true;
|
||||
|
||||
@state() private _width = 0;
|
||||
@state() private _hover: { x: number; y: number; p: ChartPoint } | null = null;
|
||||
|
||||
private _ro: ResizeObserver | null = null;
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this._ro = new ResizeObserver((entries) => {
|
||||
const w = Math.floor(entries[0]?.contentRect?.width || 0);
|
||||
if (w && Math.abs(w - this._width) > 2) this._width = w;
|
||||
});
|
||||
this._ro.observe(this);
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this._ro?.disconnect();
|
||||
this._ro = null;
|
||||
}
|
||||
|
||||
private _emitRange(days: number): void {
|
||||
if (days === this.rangeDays) return;
|
||||
this.dispatchEvent(new CustomEvent("range-change", { detail: { days }, bubbles: true, composed: true }));
|
||||
}
|
||||
|
||||
private _toggleOutliers(): void {
|
||||
this.dispatchEvent(new CustomEvent("outlier-toggle", {
|
||||
detail: { hide: !this.hideOutliers }, bubbles: true, composed: true,
|
||||
}));
|
||||
}
|
||||
|
||||
render() {
|
||||
const W = this._width || 320;
|
||||
const pts = [...this.points].sort((a, b) => a.ts - b.ts);
|
||||
const L = this.lang;
|
||||
|
||||
return html`
|
||||
<div class="chart-wrap">
|
||||
${this.showRange
|
||||
? html`<div class="range-chips" role="group">
|
||||
${this.showOutlierToggle
|
||||
? html`<button
|
||||
class="range-chip outlier-chip ${this.hideOutliers ? "active" : ""}"
|
||||
?disabled=${this.busy}
|
||||
title=${t("hide_outliers", L)}
|
||||
@click=${() => this._toggleOutliers()}
|
||||
><ha-icon icon="mdi:filter-variant"></ha-icon></button>`
|
||||
: nothing}
|
||||
${RANGES.map(
|
||||
(r) => html`<button
|
||||
class="range-chip ${this.rangeDays === r.days ? "active" : ""}"
|
||||
?disabled=${this.busy}
|
||||
@click=${() => this._emitRange(r.days)}
|
||||
>${t(r.key, L)}</button>`,
|
||||
)}
|
||||
</div>`
|
||||
: nothing}
|
||||
${pts.length < 2
|
||||
? html`<div class="chart-empty">
|
||||
<ha-icon icon="mdi:chart-line"></ha-icon> ${t("loading_chart", L)}
|
||||
</div>`
|
||||
: this._renderSvg(W, pts)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderSvg(W: number, pts: ChartPoint[]) {
|
||||
const L = this.lang;
|
||||
const plotW = W - PAD_L - PAD_R;
|
||||
const plotB = H - PAD_B;
|
||||
const plotH = plotB - PAD_T;
|
||||
|
||||
// Domain: data ∪ min/max band ∪ reference lines ∪ optional zero floor.
|
||||
let lo = Infinity;
|
||||
let hi = -Infinity;
|
||||
for (const p of pts) {
|
||||
lo = Math.min(lo, p.min ?? p.val);
|
||||
hi = Math.max(hi, p.max ?? p.val);
|
||||
}
|
||||
if (this.thresholdAbove != null) { lo = Math.min(lo, this.thresholdAbove); hi = Math.max(hi, this.thresholdAbove); }
|
||||
if (this.thresholdBelow != null) { lo = Math.min(lo, this.thresholdBelow); hi = Math.max(hi, this.thresholdBelow); }
|
||||
if (this.targetValue != null) { lo = Math.min(lo, this.targetValue); hi = Math.max(hi, this.targetValue); }
|
||||
if (this.forceZero) lo = Math.min(lo, 0);
|
||||
const pad = (hi - lo || 1) * 0.06;
|
||||
// A progress axis never dips below zero — padding must not create a -50 tick.
|
||||
const loPadded = this.forceZero && lo >= 0 ? 0 : lo - pad;
|
||||
let { ticks, niceMin, niceMax } = niceTicks(loPadded, hi + pad, 4);
|
||||
if (this.forceZero && lo >= 0 && niceMin < 0) {
|
||||
niceMin = 0;
|
||||
ticks = ticks.filter((v) => v >= 0);
|
||||
}
|
||||
|
||||
const tsMin = pts[0].ts;
|
||||
const tsMax = pts[pts.length - 1].ts;
|
||||
const tsSpan = tsMax - tsMin || 1;
|
||||
const withYear = needsYear(tsMin, tsMax);
|
||||
|
||||
const toX = (ts: number) => PAD_L + ((ts - tsMin) / tsSpan) * plotW;
|
||||
const toY = (v: number) => PAD_T + (1 - (v - niceMin) / (niceMax - niceMin || 1)) * plotH;
|
||||
|
||||
const linePts = pts.map((p) => `${toX(p.ts).toFixed(1)},${toY(p.val).toFixed(1)}`).join(" ");
|
||||
const areaPath =
|
||||
`M${toX(pts[0].ts).toFixed(1)},${plotB} ` +
|
||||
pts.map((p) => `L${toX(p.ts).toFixed(1)},${toY(p.val).toFixed(1)}`).join(" ") +
|
||||
` L${toX(pts[pts.length - 1].ts).toFixed(1)},${plotB} Z`;
|
||||
|
||||
// Optional min/max band behind the line.
|
||||
let bandPath = "";
|
||||
const band = pts.filter((p) => p.min != null && p.max != null);
|
||||
if (band.length >= 2) {
|
||||
const up = band.map((p) => `${toX(p.ts).toFixed(1)},${toY(p.max!).toFixed(1)}`);
|
||||
const dn = [...band].reverse().map((p) => `${toX(p.ts).toFixed(1)},${toY(p.min!).toFixed(1)}`);
|
||||
bandPath = `M${up[0]} ` + up.slice(1).map((x) => `L${x}`).join(" ") + ` L${dn.join(" L")} Z`;
|
||||
}
|
||||
|
||||
// Danger zones: rect (shading + line-color clip), the threshold line y,
|
||||
// and where its label sits (just inside the zone).
|
||||
const zones: { y: number; h: number; lineY: number; label: string; labelY: number }[] = [];
|
||||
if (this.thresholdBelow != null) {
|
||||
const zy = toY(this.thresholdBelow);
|
||||
zones.push({ y: zy, h: Math.max(0, plotB - zy), lineY: zy, label: `▼ ${fmtNum(this.thresholdBelow)}`, labelY: Math.min(plotB - 4, zy + 13) });
|
||||
}
|
||||
if (this.thresholdAbove != null) {
|
||||
const zy = toY(this.thresholdAbove);
|
||||
zones.push({ y: PAD_T, h: Math.max(0, zy - PAD_T), lineY: zy, label: `▲ ${fmtNum(this.thresholdAbove)}`, labelY: Math.max(PAD_T + 11, zy - 5) });
|
||||
}
|
||||
|
||||
const lastP = pts[pts.length - 1];
|
||||
const events = (this.events || []).filter((e) => e.ts >= tsMin && e.ts <= tsMax);
|
||||
const xTicks = timeTicks(tsMin, tsMax, Math.max(2, Math.min(5, Math.floor(plotW / 110) + 1)));
|
||||
const hover = this._hover;
|
||||
|
||||
return html`
|
||||
<div class="svg-holder">
|
||||
<svg
|
||||
class="chart-svg"
|
||||
viewBox="0 0 ${W} ${H}"
|
||||
width=${W}
|
||||
height=${H}
|
||||
role="img"
|
||||
aria-label=${t("chart_sparkline", L)}
|
||||
@pointermove=${(e: PointerEvent) => this._onPointer(e, pts, toX, toY, W)}
|
||||
@pointerdown=${(e: PointerEvent) => this._onPointer(e, pts, toX, toY, W)}
|
||||
@pointerleave=${() => (this._hover = null)}
|
||||
>
|
||||
<defs>
|
||||
<clipPath id="plot"><rect x="${PAD_L}" y="${PAD_T}" width="${plotW}" height="${plotH}" /></clipPath>
|
||||
${zones.length
|
||||
? svg`<clipPath id="danger">${zones.map((z) => svg`<rect x="${PAD_L}" y="${z.y.toFixed(1)}" width="${plotW}" height="${z.h.toFixed(1)}" />`)}</clipPath>`
|
||||
: nothing}
|
||||
<!-- Diagonal hatch so the danger zone reads without relying on the
|
||||
red tint alone (dark-theme contrast + colour-blind support). -->
|
||||
<pattern id="dangerHatch" patternUnits="userSpaceOnUse" width="7" height="7" patternTransform="rotate(45)">
|
||||
<rect width="7" height="7" fill="var(--error-color, #f44336)" opacity="0.10" />
|
||||
<line x1="0" y1="0" x2="0" y2="7" stroke="var(--error-color, #f44336)" stroke-width="1.4" opacity="0.5" />
|
||||
</pattern>
|
||||
</defs>
|
||||
|
||||
${ticks.map((v) => {
|
||||
const y = toY(v);
|
||||
if (y < PAD_T - 1 || y > plotB + 1) return nothing;
|
||||
return svg`
|
||||
<line x1="${PAD_L}" y1="${y.toFixed(1)}" x2="${W - PAD_R}" y2="${y.toFixed(1)}"
|
||||
stroke="var(--divider-color)" stroke-width="1" opacity="0.6" />
|
||||
<text x="${PAD_L - 7}" y="${(y + 3.5).toFixed(1)}" text-anchor="end" class="tick-label">${fmtNum(v)}</text>`;
|
||||
})}
|
||||
|
||||
${zones.map(
|
||||
(z) => svg`<rect x="${PAD_L}" y="${z.y.toFixed(1)}" width="${plotW}" height="${z.h.toFixed(1)}"
|
||||
fill="url(#dangerHatch)" />`,
|
||||
)}
|
||||
|
||||
${bandPath ? svg`<path d="${bandPath}" fill="var(--primary-color)" opacity="0.08" clip-path="url(#plot)" />` : nothing}
|
||||
<path d="${areaPath}" fill="var(--primary-color)" opacity="0.10" clip-path="url(#plot)" />
|
||||
<polyline points="${linePts}" fill="none" stroke="var(--primary-color)" stroke-width="2"
|
||||
stroke-linejoin="round" stroke-linecap="round" clip-path="url(#plot)" />
|
||||
${zones.length
|
||||
? svg`<polyline points="${linePts}" fill="none" stroke="var(--error-color, #f44336)" stroke-width="2"
|
||||
stroke-linejoin="round" stroke-linecap="round" clip-path="url(#danger)" />`
|
||||
: nothing}
|
||||
|
||||
${zones.map(
|
||||
(z) => svg`
|
||||
<line x1="${PAD_L}" y1="${z.lineY.toFixed(1)}" x2="${W - PAD_R}" y2="${z.lineY.toFixed(1)}"
|
||||
stroke="var(--error-color, #f44336)" stroke-width="1.5" stroke-dasharray="6,4" />
|
||||
<text x="${W - PAD_R - 4}" y="${z.labelY.toFixed(1)}" text-anchor="end" class="zone-label">${z.label}</text>`,
|
||||
)}
|
||||
|
||||
${this.targetValue != null
|
||||
? svg`<line x1="${PAD_L}" y1="${toY(this.targetValue).toFixed(1)}" x2="${W - PAD_R}" y2="${toY(this.targetValue).toFixed(1)}"
|
||||
stroke="var(--error-color, #f44336)" stroke-width="1.5" stroke-dasharray="6,4" />
|
||||
<text x="${W - PAD_R - 4}" y="${(toY(this.targetValue) - 5).toFixed(1)}" text-anchor="end" class="zone-label">◆ ${fmtNum(this.targetValue)} ${this.unit}</text>`
|
||||
: nothing}
|
||||
|
||||
${this.projection && this.projection.length === 2
|
||||
? svg`<line x1="${toX(this.projection[0].ts).toFixed(1)}" y1="${toY(this.projection[0].val).toFixed(1)}"
|
||||
x2="${Math.min(toX(this.projection[1].ts), W - PAD_R).toFixed(1)}" y2="${toY(Math.max(niceMin, Math.min(niceMax, this.projection[1].val))).toFixed(1)}"
|
||||
stroke="var(--warning-color, #ff9800)" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.8" />`
|
||||
: nothing}
|
||||
|
||||
${xTicks.map((ts, i) => {
|
||||
const x = toX(ts);
|
||||
const anchor = i === 0 ? "start" : i === xTicks.length - 1 ? "end" : "middle";
|
||||
return svg`<text x="${x.toFixed(1)}" y="${H - 5}" text-anchor="${anchor}" class="tick-label">${fmtDateTick(ts, L, withYear)}</text>`;
|
||||
})}
|
||||
|
||||
<line x1="${PAD_L}" y1="${plotB}" x2="${W - PAD_R}" y2="${plotB}" stroke="var(--divider-color)" stroke-width="1" />
|
||||
|
||||
${events.map((e) => {
|
||||
const x = toX(e.ts);
|
||||
const color =
|
||||
e.type === "completed"
|
||||
? "var(--success-color, #4caf50)"
|
||||
: e.type === "skipped"
|
||||
? "var(--warning-color, #ff9800)"
|
||||
: "var(--info-color, #2196f3)";
|
||||
return svg`
|
||||
<line x1="${x.toFixed(1)}" y1="${PAD_T}" x2="${x.toFixed(1)}" y2="${plotB}" stroke="${color}" stroke-width="1" opacity="0.14" />
|
||||
<rect x="${(x - 1.5).toFixed(1)}" y="${plotB + 3}" width="3" height="${LANE_H - 6}" rx="1.5" fill="${color}">
|
||||
<title>${fmtDateTime(e.ts, L)}</title>
|
||||
</rect>`;
|
||||
})}
|
||||
|
||||
${hover
|
||||
? svg`
|
||||
<line x1="${hover.x.toFixed(1)}" y1="${PAD_T}" x2="${hover.x.toFixed(1)}" y2="${plotB}"
|
||||
stroke="var(--secondary-text-color)" stroke-width="1" stroke-dasharray="3,3" opacity="0.7" />
|
||||
<circle cx="${hover.x.toFixed(1)}" cy="${hover.y.toFixed(1)}" r="4.5" fill="var(--primary-color)"
|
||||
stroke="var(--card-background-color, #fff)" stroke-width="2" />`
|
||||
: svg`<circle cx="${toX(lastP.ts).toFixed(1)}" cy="${toY(lastP.val).toFixed(1)}" r="4" fill="var(--primary-color)"
|
||||
stroke="var(--card-background-color, #fff)" stroke-width="1.5" />`}
|
||||
</svg>
|
||||
${hover
|
||||
? html`<div
|
||||
class="hover-chip"
|
||||
style="left:${Math.min(Math.max(hover.x, 70), W - 70)}px"
|
||||
>
|
||||
<div class="hover-date">${fmtDateTime(hover.p.ts, L)}</div>
|
||||
<div class="hover-val">
|
||||
${fmtVal(hover.p.val, this.unit, L)}
|
||||
${hover.p.min != null && hover.p.max != null
|
||||
? html`<span class="hover-range">(${fmtNum(hover.p.min)}–${fmtNum(hover.p.max)})</span>`
|
||||
: nothing}
|
||||
</div>
|
||||
</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _onPointer(
|
||||
e: PointerEvent,
|
||||
pts: ChartPoint[],
|
||||
toX: (ts: number) => number,
|
||||
toY: (v: number) => number,
|
||||
W: number,
|
||||
): void {
|
||||
const svgEl = e.currentTarget as SVGSVGElement;
|
||||
const rect = svgEl.getBoundingClientRect();
|
||||
const px = ((e.clientX - rect.left) / rect.width) * W;
|
||||
if (px < PAD_L - 8 || px > W - PAD_R + 8) {
|
||||
this._hover = null;
|
||||
return;
|
||||
}
|
||||
let best = pts[0];
|
||||
let bestD = Infinity;
|
||||
for (const p of pts) {
|
||||
const d = Math.abs(toX(p.ts) - px);
|
||||
if (d < bestD) {
|
||||
bestD = d;
|
||||
best = p;
|
||||
}
|
||||
}
|
||||
this._hover = { x: toX(best.ts), y: toY(best.val), p: best };
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host { display: block; width: 100%; }
|
||||
.chart-wrap { position: relative; }
|
||||
.range-chips { display: flex; gap: 4px; justify-content: flex-end; margin-bottom: 2px; }
|
||||
.range-chip {
|
||||
font: inherit; font-size: 11.5px; padding: 2px 9px; border-radius: 12px; cursor: pointer;
|
||||
border: 1px solid var(--divider-color); background: transparent;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
/* Outlier toggle sits left of the range chips as an icon button. */
|
||||
.outlier-chip { margin-right: auto; padding: 2px 7px; display: inline-flex; align-items: center; }
|
||||
.outlier-chip ha-icon { --mdc-icon-size: 15px; }
|
||||
.range-chip.active {
|
||||
background: var(--primary-color); border-color: var(--primary-color);
|
||||
color: var(--text-primary-color, #fff);
|
||||
}
|
||||
.range-chip[disabled] { opacity: 0.5; pointer-events: none; }
|
||||
.svg-holder { position: relative; }
|
||||
.chart-svg { display: block; touch-action: pan-y; }
|
||||
.tick-label { fill: var(--secondary-text-color); font-size: 10.5px; }
|
||||
.zone-label { fill: var(--error-color, #f44336); font-size: 11px; font-weight: 600; }
|
||||
.chart-empty {
|
||||
display: flex; align-items: center; justify-content: center; gap: 8px; height: 120px;
|
||||
color: var(--secondary-text-color); font-size: 12.5px;
|
||||
}
|
||||
.chart-empty ha-icon { --mdc-icon-size: 17px; }
|
||||
.hover-chip {
|
||||
position: absolute; top: 0; transform: translateX(-50%);
|
||||
background: var(--card-background-color, #fff); border: 1px solid var(--divider-color);
|
||||
border-radius: 8px; padding: 4px 9px; pointer-events: none; white-space: nowrap;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); z-index: 3;
|
||||
}
|
||||
.hover-date { font-size: 10.5px; color: var(--secondary-text-color); }
|
||||
.hover-val { font-size: 12.5px; font-weight: 600; color: var(--primary-text-color); }
|
||||
.hover-range { font-weight: 400; color: var(--secondary-text-color); font-size: 11px; }
|
||||
`;
|
||||
}
|
||||
|
||||
if (!customElements.get("maintenance-trigger-chart")) {
|
||||
customElements.define("maintenance-trigger-chart", MaintenanceTriggerChart);
|
||||
}
|
||||
+327
@@ -0,0 +1,327 @@
|
||||
/** v2.4.0 — Interactive Vacation Section Card.
|
||||
*
|
||||
* Replaces the read-only markdown card from Phase 5. Lets the user toggle
|
||||
* vacation mode + edit start/end/buffer inline, without leaving Lovelace.
|
||||
*
|
||||
* Used as a section-strategy card (HA 2026.5+ sections) AND as a stand-alone
|
||||
* card the user can drop onto any dashboard.
|
||||
*
|
||||
* Permissions: only admins see edit affordances; non-admins get a read-only
|
||||
* view with a deep-link to the panel (vacation config is admin-only at the
|
||||
* WS layer too).
|
||||
*/
|
||||
|
||||
import { LitElement, html, css, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { t, ensureLocale } from "../styles";
|
||||
import { describeWsError } from "../ws-errors";
|
||||
import { sectionCardSharedStyles } from "./section-card-shared-styles";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
interface VacationState {
|
||||
enabled?: boolean;
|
||||
is_active?: boolean;
|
||||
start?: string | null;
|
||||
end?: string | null;
|
||||
buffer_days?: number;
|
||||
window_end?: string | null;
|
||||
exempt_task_ids?: string[];
|
||||
}
|
||||
|
||||
interface CardConfig {
|
||||
type: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export class MaintenanceVacationSectionCard extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@state() private _config: CardConfig = { type: "" };
|
||||
@state() private _state: VacationState | null = null;
|
||||
@state() private _busy = false;
|
||||
@state() private _error = "";
|
||||
@state() private _localStart = "";
|
||||
@state() private _localEnd = "";
|
||||
@state() private _localBuffer = 7;
|
||||
@state() private _dirty = false;
|
||||
|
||||
private _loaded = false;
|
||||
|
||||
setConfig(config: CardConfig): void {
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
getCardSize(): number {
|
||||
return 2;
|
||||
}
|
||||
|
||||
private get _lang(): string {
|
||||
return this.hass?.language || "en";
|
||||
}
|
||||
|
||||
private get _isAdmin(): boolean {
|
||||
return (this.hass?.user?.is_admin ?? true) as boolean;
|
||||
}
|
||||
|
||||
updated(changedProps: Map<string, unknown>): void {
|
||||
super.updated(changedProps);
|
||||
if (changedProps.has("hass") && this.hass && !this._loaded) {
|
||||
this._loaded = true;
|
||||
void this._load();
|
||||
void ensureLocale(this._lang).then(() => this.requestUpdate());
|
||||
}
|
||||
}
|
||||
|
||||
private async _load(): Promise<void> {
|
||||
try {
|
||||
const r = await this.hass.connection.sendMessagePromise<VacationState>({
|
||||
type: "maintenance_supporter/vacation/state",
|
||||
});
|
||||
this._state = r;
|
||||
this._localStart = r.start || "";
|
||||
this._localEnd = r.end || "";
|
||||
this._localBuffer = r.buffer_days ?? 7;
|
||||
this._dirty = false;
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
}
|
||||
}
|
||||
|
||||
private async _toggleEnabled(on: boolean): Promise<void> {
|
||||
this._busy = true;
|
||||
this._error = "";
|
||||
try {
|
||||
const r = await this.hass.connection.sendMessagePromise<VacationState>({
|
||||
type: "maintenance_supporter/vacation/update",
|
||||
enabled: on,
|
||||
});
|
||||
this._state = r;
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async _save(): Promise<void> {
|
||||
if (!this._isAdmin) return;
|
||||
this._busy = true;
|
||||
this._error = "";
|
||||
try {
|
||||
const r = await this.hass.connection.sendMessagePromise<VacationState>({
|
||||
type: "maintenance_supporter/vacation/update",
|
||||
start: this._localStart || null,
|
||||
end: this._localEnd || null,
|
||||
buffer_days: this._localBuffer,
|
||||
});
|
||||
this._state = r;
|
||||
this._dirty = false;
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async _endNow(): Promise<void> {
|
||||
if (!this._isAdmin) return;
|
||||
if (!window.confirm(t("vacation_end_now_confirm", this._lang)
|
||||
|| "End vacation immediately?")) return;
|
||||
this._busy = true;
|
||||
try {
|
||||
const r = await this.hass.connection.sendMessagePromise<VacationState>({
|
||||
type: "maintenance_supporter/vacation/end_now",
|
||||
});
|
||||
this._state = r;
|
||||
this._localStart = r.start || "";
|
||||
this._localEnd = r.end || "";
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _onDeepLink(): void {
|
||||
const path = "/maintenance-supporter?ms_action=open_vacation";
|
||||
history.pushState(null, "", path);
|
||||
window.dispatchEvent(new CustomEvent("location-changed"));
|
||||
}
|
||||
|
||||
render() {
|
||||
const L = this._lang;
|
||||
const s = this._state;
|
||||
if (!s) {
|
||||
return html`<ha-card><div class="loading">${t("loading", L) || "Loading…"}</div></ha-card>`;
|
||||
}
|
||||
const active = s.is_active === true;
|
||||
const enabled = s.enabled === true;
|
||||
const exemptCount = s.exempt_task_ids?.length ?? 0;
|
||||
const statusLabel = active
|
||||
? (t("vacation_status_active", L) || "Active now")
|
||||
: enabled
|
||||
? (t("vacation_status_scheduled", L) || "Scheduled")
|
||||
: (t("vacation_status_inactive", L) || "Inactive");
|
||||
const statusClass = active ? "active" : enabled ? "scheduled" : "inactive";
|
||||
|
||||
return html`
|
||||
<ha-card>
|
||||
<div class="card-content">
|
||||
<div class="header">
|
||||
<div class="title">
|
||||
<span class="emoji">🏖️</span>
|
||||
<span>${this._config.title || (t("vacation_mode", L) || "Vacation mode")}</span>
|
||||
</div>
|
||||
<span class="status-pill ${statusClass}">${statusLabel}</span>
|
||||
</div>
|
||||
|
||||
${this._error ? html`<div class="error">${this._error}</div>` : nothing}
|
||||
|
||||
${this._isAdmin
|
||||
? html`
|
||||
<div class="row toggle-row">
|
||||
<label>${t("enable", L) || "Enable"}</label>
|
||||
<ha-switch
|
||||
.checked=${enabled}
|
||||
.disabled=${this._busy}
|
||||
@change=${(e: Event) =>
|
||||
this._toggleEnabled((e.target as HTMLInputElement).checked)}
|
||||
></ha-switch>
|
||||
</div>
|
||||
|
||||
<div class="dates-row">
|
||||
<div class="date-field">
|
||||
<label>${t("vacation_start", L) || "Start"}</label>
|
||||
<input type="date" .value=${this._localStart}
|
||||
?disabled=${this._busy}
|
||||
@input=${(e: Event) => {
|
||||
this._localStart = (e.target as HTMLInputElement).value;
|
||||
this._dirty = true;
|
||||
}} />
|
||||
</div>
|
||||
<div class="date-field">
|
||||
<label>${t("vacation_end", L) || "End"}</label>
|
||||
<input type="date" .value=${this._localEnd}
|
||||
?disabled=${this._busy}
|
||||
@input=${(e: Event) => {
|
||||
this._localEnd = (e.target as HTMLInputElement).value;
|
||||
this._dirty = true;
|
||||
}} />
|
||||
</div>
|
||||
<div class="date-field buffer">
|
||||
<label>${t("vacation_buffer", L) || "Buffer days"}</label>
|
||||
<input type="number" min="0" max="14"
|
||||
.value=${String(this._localBuffer)}
|
||||
?disabled=${this._busy}
|
||||
@input=${(e: Event) => {
|
||||
this._localBuffer = parseInt(
|
||||
(e.target as HTMLInputElement).value, 10) || 0;
|
||||
this._dirty = true;
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn ${this._dirty ? "primary" : "muted"}"
|
||||
@click=${this._save}
|
||||
?disabled=${this._busy || !this._dirty}>
|
||||
<ha-icon icon="${this._dirty ? "mdi:content-save" : "mdi:check"}"></ha-icon>
|
||||
${this._dirty
|
||||
? (t("save", L) || "Save")
|
||||
: (t("saved", L) || "Saved")}
|
||||
</button>
|
||||
${active
|
||||
? html`<button class="btn"
|
||||
@click=${this._endNow}
|
||||
?disabled=${this._busy}>
|
||||
${t("vacation_end_now", L) || "End now"}
|
||||
</button>`
|
||||
: nothing}
|
||||
${exemptCount > 0
|
||||
? html`<button class="btn link"
|
||||
@click=${this._onDeepLink}>
|
||||
${exemptCount} ${t("vacation_exempt_count", L) || "exempt"}…
|
||||
</button>`
|
||||
: html`<button class="btn link"
|
||||
@click=${this._onDeepLink}>
|
||||
${t("vacation_advanced", L) || "Advanced…"}
|
||||
</button>`}
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<div class="readonly">
|
||||
${enabled && s.start && s.end
|
||||
? html`<div>${s.start} → ${s.end}</div>`
|
||||
: nothing}
|
||||
<button class="btn link" @click=${this._onDeepLink}>
|
||||
${t("vacation_open_panel", L) || "Open in panel"}
|
||||
</button>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = [sectionCardSharedStyles, css`
|
||||
.status-pill {
|
||||
font-size: 11px; font-weight: 600;
|
||||
padding: 3px 8px; border-radius: 999px;
|
||||
text-transform: uppercase; letter-spacing: 0.5px;
|
||||
}
|
||||
.status-pill.active {
|
||||
background: rgba(76, 175, 80, 0.15);
|
||||
color: #4caf50;
|
||||
}
|
||||
.status-pill.scheduled {
|
||||
background: rgba(255, 152, 0, 0.15);
|
||||
color: #ff9800;
|
||||
}
|
||||
.status-pill.inactive {
|
||||
background: rgba(158, 158, 158, 0.15);
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.row.toggle-row {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
}
|
||||
.row.toggle-row label {
|
||||
font-size: 14px; color: var(--primary-text-color);
|
||||
}
|
||||
.dates-row {
|
||||
display: grid; grid-template-columns: 1fr 1fr 100px; gap: 10px;
|
||||
}
|
||||
.date-field.buffer label { white-space: nowrap; }
|
||||
.date-field { display: flex; flex-direction: column; gap: 4px; }
|
||||
.date-field label {
|
||||
font-size: 11px; color: var(--secondary-text-color);
|
||||
text-transform: uppercase; letter-spacing: 0.3px;
|
||||
}
|
||||
.date-field input {
|
||||
padding: 6px 8px; font-size: 13px;
|
||||
background: var(--secondary-background-color, #2c2c2c);
|
||||
color: var(--primary-text-color);
|
||||
border: 1px solid var(--divider-color); border-radius: 6px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.date-field input:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.actions {
|
||||
display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
|
||||
}
|
||||
.readonly { display: flex; flex-direction: column; gap: 8px; }
|
||||
`];
|
||||
}
|
||||
|
||||
if (!customElements.get("maintenance-vacation-section-card")) {
|
||||
customElements.define(
|
||||
"maintenance-vacation-section-card",
|
||||
MaintenanceVacationSectionCard,
|
||||
);
|
||||
}
|
||||
|
||||
(window as { customCards?: unknown[] }).customCards =
|
||||
(window as { customCards?: unknown[] }).customCards || [];
|
||||
((window as { customCards?: unknown[] }).customCards!).push({
|
||||
type: "maintenance-vacation-section-card",
|
||||
name: "Maintenance Supporter — Vacation",
|
||||
description: "Inline vacation mode toggle + dates",
|
||||
preview: false,
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const HA = "http://homeassistant-dev:8123";
|
||||
const REFRESH = process.env.HA_REFRESH_TOKEN;
|
||||
if (!REFRESH) { console.error("Set HA_REFRESH_TOKEN env var"); process.exit(1); }
|
||||
|
||||
const browser = await chromium.connect("ws://localhost:3000");
|
||||
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 }, locale: "de" });
|
||||
const page = await ctx.newPage();
|
||||
|
||||
await page.goto(HA);
|
||||
await page.waitForTimeout(1000);
|
||||
await page.evaluate((refresh) => {
|
||||
localStorage.setItem("hassTokens", JSON.stringify({
|
||||
hassUrl: "http://homeassistant-dev:8123",
|
||||
clientId: "http://homeassistant-dev:8123/",
|
||||
refresh_token: refresh, access_token: "", token_type: "Bearer",
|
||||
expires_in: 1800, expires: 0,
|
||||
}));
|
||||
}, REFRESH);
|
||||
|
||||
// Test 1: Deep-link with valid entry_id + task_id
|
||||
console.log("=== Test 1: Valid deep-link ===");
|
||||
await page.goto(HA + "/maintenance-supporter?entry_id=01KJTX1VHBT6TN5JMF9W6WADTJ&task_id=49c49ecf36204031be94c8f132aa6479");
|
||||
await page.waitForTimeout(6000);
|
||||
var url1 = page.url();
|
||||
console.log("URL after deep-link:", url1);
|
||||
console.log("Query params cleaned:", !url1.includes("entry_id"));
|
||||
await page.screenshot({ path: "screenshots/deeplink_valid.png" });
|
||||
|
||||
// Test 2: Deep-link with invalid entry_id
|
||||
console.log("\n=== Test 2: Invalid entry_id ===");
|
||||
await page.goto(HA + "/maintenance-supporter?entry_id=NONEXISTENT");
|
||||
await page.waitForTimeout(6000);
|
||||
var url2 = page.url();
|
||||
console.log("URL after invalid deep-link:", url2);
|
||||
console.log("Query params cleaned:", !url2.includes("entry_id"));
|
||||
await page.screenshot({ path: "screenshots/deeplink_invalid.png" });
|
||||
|
||||
// Test 3: Deep-link with valid entry but invalid task
|
||||
console.log("\n=== Test 3: Invalid task_id ===");
|
||||
await page.goto(HA + "/maintenance-supporter?entry_id=01KJTX1VHBT6TN5JMF9W6WADTJ&task_id=NONEXISTENT");
|
||||
await page.waitForTimeout(6000);
|
||||
var url3 = page.url();
|
||||
console.log("URL after invalid task deep-link:", url3);
|
||||
await page.screenshot({ path: "screenshots/deeplink_invalid_task.png" });
|
||||
|
||||
await ctx.close();
|
||||
await browser.close();
|
||||
console.log("\nDone!");
|
||||
@@ -0,0 +1,300 @@
|
||||
/** Standalone dialog mounting helper.
|
||||
*
|
||||
* Mounts the existing MaintenanceObjectDialog / MaintenanceTaskDialog onto
|
||||
* document.body so they can be opened from any Lovelace context — without
|
||||
* the user navigating to the panel first.
|
||||
*
|
||||
* Usage from a strategy or card click handler:
|
||||
*
|
||||
* import { openCreateObjectDialog, openTaskDialog } from "./dialog-mount";
|
||||
* openCreateObjectDialog();
|
||||
*
|
||||
* The hass instance is pulled from <home-assistant>.hass at open time and
|
||||
* re-injected on every Lovelace re-render so the dialog stays connected to
|
||||
* a fresh WS connection across HA reconnects.
|
||||
*
|
||||
* Each dialog is created once and re-used. Closing the dialog (via its own
|
||||
* Cancel/Save buttons) unsets ``_open`` internally; we never destroy it.
|
||||
*/
|
||||
|
||||
import "./components/object-dialog";
|
||||
import "./components/task-dialog";
|
||||
import "./components/complete-dialog";
|
||||
import "./components/history-edit-dialog";
|
||||
import "./components/qr-dialog";
|
||||
import "./components/task-quick-actions-dialog";
|
||||
import "./components/object-quick-actions-dialog";
|
||||
import type { MaintenanceObjectDialog } from "./components/object-dialog";
|
||||
import type { MaintenanceTaskDialog } from "./components/task-dialog";
|
||||
import type {
|
||||
MaintenanceHistoryEditDialog,
|
||||
HistoryEntryDraft,
|
||||
} from "./components/history-edit-dialog";
|
||||
import type { MaintenanceCompleteDialog } from "./components/complete-dialog";
|
||||
import type { MaintenanceQrDialog } from "./components/qr-dialog";
|
||||
import type { MaintenanceTaskQuickActionsDialog } from "./components/task-quick-actions-dialog";
|
||||
import type { MaintenanceObjectQuickActionsDialog } from "./components/object-quick-actions-dialog";
|
||||
import type { HomeAssistant, MaintenanceObject } from "./types";
|
||||
import { ensureLocale, isLocaleLoaded } from "./styles";
|
||||
|
||||
const OBJECT_DIALOG_TAG = "maintenance-object-dialog";
|
||||
const TASK_DIALOG_TAG = "maintenance-task-dialog";
|
||||
const HISTORY_EDIT_DIALOG_TAG = "maintenance-history-edit-dialog";
|
||||
const COMPLETE_DIALOG_TAG = "maintenance-complete-dialog";
|
||||
const QR_DIALOG_TAG = "maintenance-qr-dialog";
|
||||
const QUICK_ACTIONS_DIALOG_TAG = "maintenance-task-quick-actions-dialog";
|
||||
const OBJECT_QUICK_ACTIONS_DIALOG_TAG = "maintenance-object-quick-actions-dialog";
|
||||
|
||||
interface HassRoot extends HTMLElement {
|
||||
hass?: HomeAssistant;
|
||||
}
|
||||
|
||||
function getHass(): HomeAssistant | undefined {
|
||||
const root = document.querySelector<HassRoot>("home-assistant");
|
||||
return root?.hass;
|
||||
}
|
||||
|
||||
function getOrCreate<T extends HTMLElement>(tag: string): T {
|
||||
let el = document.body.querySelector<T>(tag);
|
||||
if (!el) {
|
||||
el = document.createElement(tag) as T;
|
||||
document.body.appendChild(el);
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
function syncHass(el: HTMLElement & { hass?: HomeAssistant }): boolean {
|
||||
const hass = getHass();
|
||||
if (!hass) return false;
|
||||
el.hass = hass;
|
||||
// Dialogs opened from the dashboard strategy live in a lazily-loaded chunk
|
||||
// that never ran the panel's ensureLocale — so without this the popup renders
|
||||
// in English even when the rest of HA is localized. Fetch the user's locale
|
||||
// (idempotent + cached) and force a re-render once it's in, so the first paint
|
||||
// (which may be English) is corrected to the user's language.
|
||||
const lang = hass.language || "en";
|
||||
if (!isLocaleLoaded(lang)) {
|
||||
void ensureLocale(lang).then(() => {
|
||||
(el as unknown as { requestUpdate?: () => void }).requestUpdate?.();
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Settings cache used to populate task-dialog's feature-gated sections
|
||||
* (checklists / schedule_time / completion_actions) AND its
|
||||
* defaultWarningDays prop when the dialog is mounted from Lovelace.
|
||||
*
|
||||
* Without this, every section that's gated on a feature flag stays
|
||||
* hidden (default false) — that's the "ich habe nicht alles wie im
|
||||
* Panel gefunden" symptom. The panel reads the same settings object
|
||||
* via maintenance-panel.ts and passes it through as element properties.
|
||||
*
|
||||
* Cache lifetime: page session. Invalidated on full HA reload (which
|
||||
* also drops our custom-element registry). The settings WS itself is
|
||||
* cheap (~10ms) so even uncached this isn't a hot path concern, but
|
||||
* caching means re-opening the dialog feels instant.
|
||||
*/
|
||||
interface SettingsCache {
|
||||
features: {
|
||||
adaptive: boolean; predictions: boolean; seasonal: boolean;
|
||||
environmental: boolean; budget: boolean; groups: boolean;
|
||||
checklists: boolean; schedule_time: boolean; completion_actions: boolean;
|
||||
};
|
||||
defaultWarningDays: number;
|
||||
}
|
||||
|
||||
const FALLBACK_SETTINGS: SettingsCache = {
|
||||
features: {
|
||||
adaptive: false, predictions: false, seasonal: false,
|
||||
environmental: false, budget: false, groups: false,
|
||||
checklists: false, schedule_time: false, completion_actions: false,
|
||||
},
|
||||
defaultWarningDays: 7,
|
||||
};
|
||||
|
||||
let _cachedSettings: Promise<SettingsCache> | null = null;
|
||||
|
||||
function fetchSettingsOnce(hass: HomeAssistant): Promise<SettingsCache> {
|
||||
if (_cachedSettings) return _cachedSettings;
|
||||
_cachedSettings = hass.connection
|
||||
.sendMessagePromise<{
|
||||
features?: SettingsCache["features"];
|
||||
general?: { default_warning_days?: number };
|
||||
}>({ type: "maintenance_supporter/settings" })
|
||||
.then((r) => ({
|
||||
features: r.features ?? FALLBACK_SETTINGS.features,
|
||||
defaultWarningDays: r.general?.default_warning_days ?? 7,
|
||||
}))
|
||||
.catch(() => FALLBACK_SETTINGS);
|
||||
return _cachedSettings;
|
||||
}
|
||||
|
||||
export function openCreateObjectDialog(): boolean {
|
||||
const dlg = getOrCreate<MaintenanceObjectDialog>(OBJECT_DIALOG_TAG);
|
||||
if (!syncHass(dlg)) return false;
|
||||
// openCreate is defined on MaintenanceObjectDialog
|
||||
dlg.openCreate();
|
||||
return true;
|
||||
}
|
||||
|
||||
export function openEditObjectDialog(
|
||||
entryId: string,
|
||||
obj: MaintenanceObject,
|
||||
): boolean {
|
||||
const dlg = getOrCreate<MaintenanceObjectDialog>(OBJECT_DIALOG_TAG);
|
||||
if (!syncHass(dlg)) return false;
|
||||
dlg.openEdit(entryId, obj);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function openCreateTaskDialog(): boolean {
|
||||
const dlg = getOrCreate<MaintenanceTaskDialog>(TASK_DIALOG_TAG);
|
||||
if (!syncHass(dlg)) return false;
|
||||
const hass = getHass();
|
||||
if (!hass) return false;
|
||||
void (async () => {
|
||||
const settings = await fetchSettingsOnce(hass);
|
||||
const dlgFull = dlg as MaintenanceTaskDialog & {
|
||||
checklistsEnabled: boolean;
|
||||
scheduleTimeEnabled: boolean;
|
||||
completionActionsEnabled: boolean;
|
||||
defaultWarningDays: number;
|
||||
openCreate: (entryId?: string) => void;
|
||||
};
|
||||
dlgFull.checklistsEnabled = settings.features.checklists;
|
||||
dlgFull.scheduleTimeEnabled = settings.features.schedule_time;
|
||||
dlgFull.completionActionsEnabled = settings.features.completion_actions;
|
||||
dlgFull.defaultWarningDays = settings.defaultWarningDays;
|
||||
dlgFull.openCreate();
|
||||
})();
|
||||
return true;
|
||||
}
|
||||
|
||||
export function openEditTaskDialog(
|
||||
entryId: string,
|
||||
taskId: string,
|
||||
): boolean {
|
||||
const dlg = getOrCreate<MaintenanceTaskDialog>(TASK_DIALOG_TAG);
|
||||
if (!syncHass(dlg)) return false;
|
||||
|
||||
// openEdit on the panel-side expects a FULL MaintenanceTask (it accesses
|
||||
// task.warning_days.toString(), task.checklist, task.adaptive_config etc.
|
||||
// unconditionally on hydrate). For the Lovelace-mount path we only have
|
||||
// (entry_id, task_id) at the click site, so we MUST fetch the full task
|
||||
// via WS before calling openEdit.
|
||||
//
|
||||
// Earlier the comment claimed "the dialog re-loads task data via WS in
|
||||
// its own openEdit handler when given just an id" — that was aspirational
|
||||
// but never implemented. Calling openEdit with a stub `{id: taskId}`
|
||||
// crashed silently with TypeError: Cannot read properties of undefined
|
||||
// (reading 'toString'). Reported as #50-followup ("strategy dashboard
|
||||
// edit button geht nicht mehr").
|
||||
//
|
||||
// The fetch is fire-and-forget — boolean return is kept for back-compat
|
||||
// (true = mount succeeded, false = no hass yet). Errors during fetch are
|
||||
// logged + the dialog stays closed.
|
||||
const hass = getHass();
|
||||
if (!hass) return false;
|
||||
void (async () => {
|
||||
try {
|
||||
// Fetch features + task in parallel — both are needed before openEdit.
|
||||
// Without features, the completion-action / checklist / schedule-time
|
||||
// sections stay hidden (their @property defaults to false), which is
|
||||
// the "im Dashboard nicht alles wie im Panel" symptom.
|
||||
const [r, settings] = await Promise.all([
|
||||
hass.connection.sendMessagePromise<{
|
||||
tasks?: Array<{ id: string } & Record<string, unknown>>;
|
||||
}>({ type: "maintenance_supporter/object", entry_id: entryId }),
|
||||
fetchSettingsOnce(hass),
|
||||
]);
|
||||
const fullTask = (r.tasks || []).find((t) => t.id === taskId);
|
||||
if (!fullTask) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`openEditTaskDialog: task ${taskId} not found in entry ${entryId}`);
|
||||
return;
|
||||
}
|
||||
const dlgFull = dlg as MaintenanceTaskDialog & {
|
||||
checklistsEnabled: boolean;
|
||||
scheduleTimeEnabled: boolean;
|
||||
completionActionsEnabled: boolean;
|
||||
defaultWarningDays: number;
|
||||
openEdit: (entryId: string, task: unknown) => Promise<void>;
|
||||
};
|
||||
dlgFull.checklistsEnabled = settings.features.checklists;
|
||||
dlgFull.scheduleTimeEnabled = settings.features.schedule_time;
|
||||
dlgFull.completionActionsEnabled = settings.features.completion_actions;
|
||||
dlgFull.defaultWarningDays = settings.defaultWarningDays;
|
||||
await dlgFull.openEdit(entryId, fullTask);
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("openEditTaskDialog: failed to load task/features", e);
|
||||
}
|
||||
})();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** v2.2.0: open the history-entry editor in place, e.g. from a calendar
|
||||
* card past-event click. The caller fetches the existing entry data via
|
||||
* the maintenance_supporter/object WS first (or uses what the calendar
|
||||
* event already carries). */
|
||||
export function openHistoryEditDialog(draft: HistoryEntryDraft): boolean {
|
||||
const dlg = getOrCreate<MaintenanceHistoryEditDialog>(HISTORY_EDIT_DIALOG_TAG);
|
||||
if (!syncHass(dlg)) return false;
|
||||
dlg.openEdit(draft);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** v2.3.0: open the rich complete-dialog from any Lovelace context. */
|
||||
export function openCompleteDialog(args: {
|
||||
entry_id: string;
|
||||
task_id: string;
|
||||
task_name: string;
|
||||
checklist?: string[];
|
||||
adaptive_enabled?: boolean;
|
||||
}): boolean {
|
||||
const dlg = getOrCreate<MaintenanceCompleteDialog>(COMPLETE_DIALOG_TAG);
|
||||
if (!syncHass(dlg)) return false;
|
||||
dlg.entryId = args.entry_id;
|
||||
dlg.taskId = args.task_id;
|
||||
dlg.taskName = args.task_name;
|
||||
dlg.checklist = args.checklist ?? [];
|
||||
dlg.adaptiveEnabled = !!args.adaptive_enabled;
|
||||
dlg.lang = (getHass()?.language) || "en";
|
||||
dlg.open();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** v2.3.0: open the QR dialog for a single task. */
|
||||
export function openQrDialog(args: {
|
||||
entry_id: string;
|
||||
task_id: string;
|
||||
task_name: string;
|
||||
object_name: string;
|
||||
}): boolean {
|
||||
const dlg = getOrCreate<MaintenanceQrDialog>(QR_DIALOG_TAG);
|
||||
if (!syncHass(dlg)) return false;
|
||||
dlg.openForTask(args.entry_id, args.task_id, args.object_name, args.task_name);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** v2.3.0: open the task quick-actions dialog (Complete/Skip/Reset/Edit/QR/Delete).
|
||||
* This is the primary entry point from a card row click. */
|
||||
export function openTaskQuickActions(entryId: string, taskId: string): boolean {
|
||||
const dlg = getOrCreate<MaintenanceTaskQuickActionsDialog>(
|
||||
QUICK_ACTIONS_DIALOG_TAG,
|
||||
);
|
||||
if (!syncHass(dlg)) return false;
|
||||
void dlg.openFor(entryId, taskId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** v2.3.0 Phase 3: open the object quick-actions dialog (Edit/Add-task/Delete + read-only meta + task list). */
|
||||
export function openObjectQuickActions(entryId: string): boolean {
|
||||
const dlg = getOrCreate<MaintenanceObjectQuickActionsDialog>(
|
||||
OBJECT_QUICK_ACTIONS_DIALOG_TAG,
|
||||
);
|
||||
if (!syncHass(dlg)) return false;
|
||||
void dlg.openFor(entryId);
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Verifies the read-only checklist preview in the task detail (overview tab).
|
||||
* Adds a task with a checklist via WS, navigates to its detail, asserts the
|
||||
* preview card with all 3 steps is in the DOM.
|
||||
*/
|
||||
import { setup, ws, cleanup } from "./e2e-helpers.mjs";
|
||||
|
||||
const RUN = Date.now().toString(36);
|
||||
const OBJ_NAME = `Checklist Preview ${RUN}`;
|
||||
const STEPS = ["Drain water", "Replace filter cartridge", "Run flush cycle"];
|
||||
|
||||
const log = (...a) => console.log(...a);
|
||||
let pass = 0, fail = 0;
|
||||
const check = (label, ok, detail) => {
|
||||
if (ok) { pass++; log(` ✓ ${label}`); }
|
||||
else { fail++; log(` ✗ ${label}${detail ? " — " + detail : ""}`); }
|
||||
};
|
||||
|
||||
const PANEL_EVAL = `
|
||||
window._ha=document.querySelector('home-assistant');
|
||||
window._main=_ha.shadowRoot.querySelector('home-assistant-main');
|
||||
window._drawer=_main.shadowRoot.querySelector('ha-drawer');
|
||||
window._resolver=_drawer.querySelector('partial-panel-resolver');
|
||||
window._custom=_resolver.querySelector('ha-panel-custom');
|
||||
window._panel=_custom.querySelector('maintenance-supporter-panel');
|
||||
window._sr=_panel.shadowRoot;
|
||||
`;
|
||||
|
||||
async function main() {
|
||||
const { browser, ctx, page } = await setup({ mobile: false });
|
||||
|
||||
log("\n== Step 1: enable Checklists feature");
|
||||
await ws(page, {
|
||||
type: "maintenance_supporter/global/update",
|
||||
settings: { advanced_checklists_visible: true },
|
||||
});
|
||||
|
||||
log("\n== Step 2: create object + task with checklist");
|
||||
const obj = await ws(page, { type: "maintenance_supporter/object/create", name: OBJ_NAME });
|
||||
const taskRes = await ws(page, {
|
||||
type: "maintenance_supporter/task/create",
|
||||
entry_id: obj.entry_id, name: "Filter Maintenance", interval_days: 30,
|
||||
checklist: STEPS,
|
||||
});
|
||||
log(" created task_id =", taskRes.task_id);
|
||||
|
||||
log("\n== Step 3: wait for panel to refresh + select the task");
|
||||
await page.waitForTimeout(2500);
|
||||
await page.evaluate(({ eid, tid, fn }) => {
|
||||
eval(fn);
|
||||
window._panel._selectedEntryId = eid;
|
||||
window._panel._selectedTaskId = tid;
|
||||
window._panel._view = "task";
|
||||
window._panel.requestUpdate();
|
||||
}, { eid: obj.entry_id, tid: taskRes.task_id, fn: PANEL_EVAL });
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
log("\n== Step 4: inspect overview tab DOM for the checklist preview card");
|
||||
const result = await page.evaluate(({ fn }) => {
|
||||
eval(fn);
|
||||
const sr = window._sr;
|
||||
const card = sr.querySelector(".checklist-preview-card");
|
||||
const header = card?.querySelector(".checklist-preview-header")?.textContent?.trim();
|
||||
const items = [...(card?.querySelectorAll("li") || [])].map((li) => li.textContent?.trim());
|
||||
return {
|
||||
hasCard: !!card,
|
||||
header,
|
||||
items,
|
||||
cardOffsetTop: card?.offsetTop,
|
||||
};
|
||||
}, { fn: PANEL_EVAL });
|
||||
log(" hasCard:", result.hasCard);
|
||||
log(" header:", JSON.stringify(result.header));
|
||||
log(" items:", JSON.stringify(result.items));
|
||||
log(" offsetTop:", result.cardOffsetTop);
|
||||
|
||||
check("preview card rendered in task detail", result.hasCard === true, "");
|
||||
check("header shows count = 3", result.header?.includes("(3)"), `header="${result.header}"`);
|
||||
check("all 3 items rendered in order",
|
||||
JSON.stringify(result.items) === JSON.stringify(STEPS),
|
||||
`items=${JSON.stringify(result.items)}`);
|
||||
|
||||
await page.screenshot({ path: "checklist-preview.png", fullPage: false });
|
||||
log(" saved checklist-preview.png");
|
||||
|
||||
log("\n== Step 5: verify preview is HIDDEN when checklist is empty");
|
||||
const obj2 = await ws(page, { type: "maintenance_supporter/object/create", name: `Empty ${RUN}` });
|
||||
const t2 = await ws(page, {
|
||||
type: "maintenance_supporter/task/create",
|
||||
entry_id: obj2.entry_id, name: "Bare Task", interval_days: 7,
|
||||
});
|
||||
await page.waitForTimeout(1500);
|
||||
await page.evaluate(({ eid, tid, fn }) => {
|
||||
eval(fn);
|
||||
window._panel._selectedEntryId = eid;
|
||||
window._panel._selectedTaskId = tid;
|
||||
window._panel._view = "task";
|
||||
window._panel.requestUpdate();
|
||||
}, { eid: obj2.entry_id, tid: t2.task_id, fn: PANEL_EVAL });
|
||||
await page.waitForTimeout(1000);
|
||||
const empty = await page.evaluate(({ fn }) => {
|
||||
eval(fn);
|
||||
return !!window._sr.querySelector(".checklist-preview-card");
|
||||
}, { fn: PANEL_EVAL });
|
||||
check("preview hidden for tasks without checklist", empty === false, "");
|
||||
|
||||
log("\n== Cleanup");
|
||||
await ws(page, { type: "maintenance_supporter/object/delete", entry_id: obj.entry_id });
|
||||
await ws(page, { type: "maintenance_supporter/object/delete", entry_id: obj2.entry_id });
|
||||
await cleanup(browser, ctx);
|
||||
|
||||
log(`\n=== ${pass} passed, ${fail} failed ===`);
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Reproduces issue #32 item 1: enable Checklists feature, then verify
|
||||
* the textarea is actually visible in the task dialog.
|
||||
*
|
||||
* Run via:
|
||||
* docker exec playwright-server sh -c \
|
||||
* 'cd /tmp && node /work/frontend-src/e2e-checklist-visibility.mjs'
|
||||
* or from host with the helper container.
|
||||
*/
|
||||
import { setup, ws, cleanup } from "./e2e-helpers.mjs";
|
||||
|
||||
const RUN = Date.now().toString(36);
|
||||
const OBJ_NAME = `Checklist Probe ${RUN}`;
|
||||
|
||||
const log = (...args) => console.log(...args);
|
||||
let pass = 0, fail = 0;
|
||||
const check = (label, ok, detail) => {
|
||||
if (ok) { pass++; log(` ✓ ${label}`); }
|
||||
else { fail++; log(` ✗ ${label}${detail ? " — " + detail : ""}`); }
|
||||
};
|
||||
|
||||
const PANEL_EVAL = `
|
||||
window._ha=document.querySelector('home-assistant');
|
||||
window._main=_ha.shadowRoot.querySelector('home-assistant-main');
|
||||
window._drawer=_main.shadowRoot.querySelector('ha-drawer');
|
||||
window._resolver=_drawer.querySelector('partial-panel-resolver');
|
||||
window._custom=_resolver.querySelector('ha-panel-custom');
|
||||
window._panel=_custom.querySelector('maintenance-supporter-panel');
|
||||
window._sr=_panel.shadowRoot;
|
||||
`;
|
||||
|
||||
async function main() {
|
||||
const { browser, ctx, page } = await setup({ mobile: false });
|
||||
|
||||
log("\n== Step 1: read /settings (initial)");
|
||||
let settings = await ws(page, { type: "maintenance_supporter/settings" });
|
||||
log(" features.checklists =", settings.features.checklists);
|
||||
|
||||
log("\n== Step 2: enable Checklists via /global/update");
|
||||
settings = await ws(page, {
|
||||
type: "maintenance_supporter/global/update",
|
||||
settings: { advanced_checklists_visible: true },
|
||||
});
|
||||
check("backend reports checklists=true", settings.features.checklists === true,
|
||||
`actual=${settings.features.checklists}`);
|
||||
|
||||
log("\n== Step 3: wait for panel to react to settings event");
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
log("\n== Step 4: read panel's _features");
|
||||
const panelFeatures = await page.evaluate((fn) => {
|
||||
eval(fn);
|
||||
return window._panel._features;
|
||||
}, PANEL_EVAL);
|
||||
log(" _features =", JSON.stringify(panelFeatures));
|
||||
check("panel._features.checklists is true", panelFeatures?.checklists === true,
|
||||
`panel sees checklists=${panelFeatures?.checklists}`);
|
||||
|
||||
log("\n== Step 5: create object + task to open dialog against");
|
||||
const obj = await ws(page, { type: "maintenance_supporter/object/create", name: OBJ_NAME });
|
||||
const taskRes = await ws(page, {
|
||||
type: "maintenance_supporter/task/create",
|
||||
entry_id: obj.entry_id, name: "Probe Task", interval_days: 7,
|
||||
});
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
log("\n== Step 6: open task-dialog in EDIT mode");
|
||||
const objWithTasks = await ws(page, {
|
||||
type: "maintenance_supporter/object", entry_id: obj.entry_id,
|
||||
});
|
||||
const task = objWithTasks.tasks.find(t => t.id === taskRes.task_id);
|
||||
await page.evaluate(({ eid, task, fn }) => {
|
||||
eval(fn);
|
||||
window._sr.querySelector("maintenance-task-dialog").openEdit(eid, task);
|
||||
}, { eid: obj.entry_id, task, fn: PANEL_EVAL });
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
log("\n== Step 7: inspect the dialog DOM for the checklist textarea");
|
||||
const dlgState = await page.evaluate((fn) => {
|
||||
eval(fn);
|
||||
const dlg = window._sr.querySelector("maintenance-task-dialog");
|
||||
const dlgSr = dlg?.shadowRoot;
|
||||
return {
|
||||
dialogExists: !!dlg,
|
||||
checklistsEnabled: dlg?.checklistsEnabled,
|
||||
open: dlg?._open,
|
||||
hasTextarea: !!dlgSr?.querySelector("textarea.checklist-textarea"),
|
||||
hasFieldLabel: !!dlgSr?.querySelector('label[for="checklist-textarea"]'),
|
||||
labelText: dlgSr?.querySelector('label[for="checklist-textarea"]')?.textContent?.trim(),
|
||||
// grab the rendered HTML around the env field for reference
|
||||
contentSnippet: dlgSr?.querySelector(".content")?.innerHTML?.slice(0, 1500),
|
||||
};
|
||||
}, PANEL_EVAL);
|
||||
|
||||
log(" dialogExists:", dlgState.dialogExists);
|
||||
log(" checklistsEnabled prop:", dlgState.checklistsEnabled);
|
||||
log(" dialog open:", dlgState.open);
|
||||
log(" hasTextarea:", dlgState.hasTextarea);
|
||||
log(" hasFieldLabel:", dlgState.hasFieldLabel);
|
||||
log(" labelText:", dlgState.labelText);
|
||||
check("checklistsEnabled prop is true on the dialog",
|
||||
dlgState.checklistsEnabled === true,
|
||||
`prop=${dlgState.checklistsEnabled}`);
|
||||
check("checklist textarea is in the DOM",
|
||||
dlgState.hasTextarea === true,
|
||||
"the textarea.checklist-textarea was not found inside the dialog");
|
||||
check("label exists for checklist textarea",
|
||||
dlgState.hasFieldLabel === true,
|
||||
"the label for=\"checklist-textarea\" was not found");
|
||||
|
||||
if (!dlgState.hasTextarea) {
|
||||
log("\n --- dialog content snippet (first 1500 chars) ---");
|
||||
log(dlgState.contentSnippet);
|
||||
}
|
||||
|
||||
log("\n== Step 8: take screenshot of edit dialog");
|
||||
await page.screenshot({ path: "/tmp/checklist-edit.png", fullPage: false });
|
||||
log(" saved /tmp/checklist-edit.png");
|
||||
|
||||
log("\n== Step 9: scroll inside dialog so the textarea is visible");
|
||||
const scrollInfo = await page.evaluate((fn) => {
|
||||
eval(fn);
|
||||
const dlgSr = window._sr.querySelector("maintenance-task-dialog").shadowRoot;
|
||||
const content = dlgSr.querySelector(".content");
|
||||
const ta = dlgSr.querySelector("textarea.checklist-textarea");
|
||||
return {
|
||||
contentScrollHeight: content?.scrollHeight,
|
||||
contentClientHeight: content?.clientHeight,
|
||||
contentOverflow: getComputedStyle(content).overflowY,
|
||||
textareaOffsetTop: ta?.offsetTop,
|
||||
textareaVisible: ta && ta.getBoundingClientRect().top < window.innerHeight,
|
||||
};
|
||||
}, PANEL_EVAL);
|
||||
log(" scroll info:", JSON.stringify(scrollInfo));
|
||||
check("dialog content is scrollable",
|
||||
scrollInfo.contentOverflow === "auto" || scrollInfo.contentOverflow === "scroll",
|
||||
`overflow-y=${scrollInfo.contentOverflow}`);
|
||||
check("textarea is reachable by scrolling within dialog",
|
||||
scrollInfo.textareaOffsetTop > 0,
|
||||
`offsetTop=${scrollInfo.textareaOffsetTop}`);
|
||||
|
||||
log("\n== Step 10: test CREATE mode (new task — does the field appear there?)");
|
||||
await page.evaluate((fn) => {
|
||||
eval(fn);
|
||||
window._sr.querySelector("maintenance-task-dialog")._open = false;
|
||||
}, PANEL_EVAL);
|
||||
await page.waitForTimeout(500);
|
||||
await page.evaluate(({ eid, fn }) => {
|
||||
eval(fn);
|
||||
window._sr.querySelector("maintenance-task-dialog").openCreate(eid);
|
||||
}, { eid: obj.entry_id, fn: PANEL_EVAL });
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const createState = await page.evaluate((fn) => {
|
||||
eval(fn);
|
||||
const dlgSr = window._sr.querySelector("maintenance-task-dialog").shadowRoot;
|
||||
return {
|
||||
hasTextarea: !!dlgSr?.querySelector("textarea.checklist-textarea"),
|
||||
labelText: dlgSr?.querySelector('label[for="checklist-textarea"]')?.textContent?.trim(),
|
||||
};
|
||||
}, PANEL_EVAL);
|
||||
log(" CREATE mode:", JSON.stringify(createState));
|
||||
check("checklist textarea also visible in CREATE dialog",
|
||||
createState.hasTextarea === true, "");
|
||||
await page.screenshot({ path: "/tmp/checklist-create.png", fullPage: false });
|
||||
log(" saved /tmp/checklist-create.png");
|
||||
|
||||
log("\n== Cleanup");
|
||||
await ws(page, {
|
||||
type: "maintenance_supporter/object/delete", entry_id: obj.entry_id,
|
||||
});
|
||||
await cleanup(browser, ctx);
|
||||
|
||||
log(`\n=== ${pass} passed, ${fail} failed ===`);
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Verifies that dialogs surface localized error messages when the WS schema
|
||||
* rejects an input. Covers the two most common shapes: length cap and range
|
||||
* cap. Asserts the error string contains the translated field label + the
|
||||
* translated constraint (e.g. "max 200 characters"), not raw voluptuous text.
|
||||
*/
|
||||
import { setup, ws, cleanup } from "./e2e-helpers.mjs";
|
||||
|
||||
const RUN = Date.now().toString(36);
|
||||
const log = (...a) => console.log(...a);
|
||||
let pass = 0, fail = 0;
|
||||
const check = (label, ok, detail) => {
|
||||
if (ok) { pass++; log(` ✓ ${label}`); }
|
||||
else { fail++; log(` ✗ ${label}${detail ? " — " + detail : ""}`); }
|
||||
};
|
||||
|
||||
const PANEL_EVAL = `
|
||||
window._ha=document.querySelector('home-assistant');
|
||||
window._main=_ha.shadowRoot.querySelector('home-assistant-main');
|
||||
window._drawer=_main.shadowRoot.querySelector('ha-drawer');
|
||||
window._resolver=_drawer.querySelector('partial-panel-resolver');
|
||||
window._custom=_resolver.querySelector('ha-panel-custom');
|
||||
window._panel=_custom.querySelector('maintenance-supporter-panel');
|
||||
window._sr=_panel.shadowRoot;
|
||||
`;
|
||||
|
||||
async function main() {
|
||||
const { browser, ctx, page } = await setup({ mobile: false });
|
||||
|
||||
log("\n== Direct WS call with oversize name (expect rejection)");
|
||||
let wsErr;
|
||||
try {
|
||||
await ws(page, {
|
||||
type: "maintenance_supporter/object/create",
|
||||
name: "X".repeat(500),
|
||||
});
|
||||
} catch (e) {
|
||||
wsErr = e;
|
||||
}
|
||||
check("WS rejected oversize name", !!wsErr, "no error raised");
|
||||
log(" raw error:", String(wsErr?.message || "").slice(0, 120));
|
||||
|
||||
log("\n== Call describeWsError from the panel with the raw error");
|
||||
const localized = await page.evaluate(({ rawMsg, fn }) => {
|
||||
eval(fn);
|
||||
// The ws-errors module is bundled into the panel; reach it via the panel instance.
|
||||
// We can approximate the test by driving the object-dialog: open it, submit
|
||||
// an oversize name, and read back the dialog's displayed error.
|
||||
return rawMsg; // just echoed for now
|
||||
}, { rawMsg: String(wsErr?.message || wsErr), fn: PANEL_EVAL });
|
||||
log(" echoed:", localized?.slice(0, 120));
|
||||
|
||||
log("\n== UI path: open object-dialog, enter oversize name, hit save");
|
||||
await page.evaluate((fn) => {
|
||||
eval(fn);
|
||||
window._sr.querySelector("maintenance-object-dialog").openCreate();
|
||||
}, PANEL_EVAL);
|
||||
await page.waitForTimeout(800);
|
||||
await page.evaluate(({ name, fn }) => {
|
||||
eval(fn);
|
||||
const dlg = window._sr.querySelector("maintenance-object-dialog");
|
||||
dlg._name = name;
|
||||
dlg.requestUpdate();
|
||||
}, { name: "X".repeat(500), fn: PANEL_EVAL });
|
||||
await page.waitForTimeout(300);
|
||||
await page.evaluate(({ fn }) => {
|
||||
eval(fn);
|
||||
const dlg = window._sr.querySelector("maintenance-object-dialog");
|
||||
dlg._save();
|
||||
}, { fn: PANEL_EVAL });
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const errText = await page.evaluate(({ fn }) => {
|
||||
eval(fn);
|
||||
const dlg = window._sr.querySelector("maintenance-object-dialog");
|
||||
return dlg._error;
|
||||
}, { fn: PANEL_EVAL });
|
||||
|
||||
log(" dialog _error:", errText);
|
||||
check(
|
||||
"dialog error is localized (contains 'too long' in EN)",
|
||||
typeof errText === "string"
|
||||
&& errText.toLowerCase().includes("too long")
|
||||
&& errText.includes("200"),
|
||||
`got: "${errText}"`,
|
||||
);
|
||||
check(
|
||||
"dialog error is NOT the raw voluptuous string",
|
||||
typeof errText === "string" && !errText.includes("dictionary value @"),
|
||||
`got raw voluptuous: "${errText}"`,
|
||||
);
|
||||
|
||||
await cleanup(browser, ctx);
|
||||
log(`\n=== ${pass} passed, ${fail} failed ===`);
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Shared helpers for Playwright E2E tests.
|
||||
*
|
||||
* Usage:
|
||||
* import { setup, ws, panelSR, cleanup } from "./e2e-helpers.mjs";
|
||||
* const { browser, page } = await setup({ mobile: false });
|
||||
*/
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const HA = "http://homeassistant-dev:8123";
|
||||
|
||||
export async function getRefreshToken() {
|
||||
const http = await import("http");
|
||||
function post(path, body, contentType = "application/json") {
|
||||
return new Promise((resolve, reject) => {
|
||||
const data = typeof body === "string" ? body : JSON.stringify(body);
|
||||
const opts = {
|
||||
hostname: "localhost", port: 8125, path, method: "POST",
|
||||
headers: { "Content-Type": contentType, "Content-Length": Buffer.byteLength(data) },
|
||||
};
|
||||
const req = http.request(opts, (res) => {
|
||||
let d = "";
|
||||
res.on("data", (c) => (d += c));
|
||||
res.on("end", () => resolve(JSON.parse(d)));
|
||||
});
|
||||
req.on("error", reject);
|
||||
req.write(data);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
const flow = await post("/auth/login_flow", { client_id: `${HA}/`, handler: ["homeassistant", null], redirect_uri: `${HA}/` });
|
||||
const auth = await post(`/auth/login_flow/${flow.flow_id}`, { client_id: `${HA}/`, username: "dev", password: "dev" });
|
||||
const tokens = await post("/auth/token", `grant_type=authorization_code&code=${auth.result}&client_id=${HA}/`, "application/x-www-form-urlencoded");
|
||||
return tokens.refresh_token;
|
||||
}
|
||||
|
||||
export async function setup({ mobile = false, timezone = undefined } = {}) {
|
||||
const refreshToken = process.env.HA_REFRESH_TOKEN || await getRefreshToken();
|
||||
const browser = await chromium.connect("ws://localhost:3000");
|
||||
const ctxOpts = {
|
||||
viewport: mobile ? { width: 375, height: 812 } : { width: 1280, height: 900 },
|
||||
locale: "en-US",
|
||||
colorScheme: "dark",
|
||||
...(mobile ? { isMobile: true } : {}),
|
||||
...(timezone ? { timezoneId: timezone } : {}),
|
||||
};
|
||||
const ctx = await browser.newContext(ctxOpts);
|
||||
const page = await ctx.newPage();
|
||||
|
||||
await page.goto(HA);
|
||||
await page.waitForTimeout(1000);
|
||||
await page.evaluate((args) => {
|
||||
localStorage.setItem("hassTokens", JSON.stringify({
|
||||
hassUrl: args.ha, clientId: args.ha + "/",
|
||||
refresh_token: args.r, access_token: "",
|
||||
token_type: "Bearer", expires_in: 1800, expires: 0,
|
||||
}));
|
||||
}, { ha: HA, r: refreshToken });
|
||||
await page.goto(HA + "/maintenance-supporter");
|
||||
await page.waitForTimeout(7000);
|
||||
|
||||
return { browser, ctx, page };
|
||||
}
|
||||
|
||||
export async function ws(page, cmd) {
|
||||
return page.evaluate(async (c) => {
|
||||
const ha = document.querySelector("home-assistant");
|
||||
if (!ha?.hass?.connection) throw new Error("no hass connection");
|
||||
try {
|
||||
return await ha.hass.connection.sendMessagePromise(c);
|
||||
} catch (e) {
|
||||
// Surface backend errors as readable JSON
|
||||
throw new Error("WS error: " + JSON.stringify(e));
|
||||
}
|
||||
}, cmd);
|
||||
}
|
||||
|
||||
export function panelSR(page) {
|
||||
return page.evaluate(() => {
|
||||
const ha = document.querySelector("home-assistant");
|
||||
const main = ha?.shadowRoot?.querySelector("home-assistant-main");
|
||||
const drawer = main?.shadowRoot?.querySelector("ha-drawer");
|
||||
const resolver = drawer?.querySelector("partial-panel-resolver");
|
||||
const custom = resolver?.querySelector("ha-panel-custom");
|
||||
const panel = custom?.querySelector("maintenance-supporter-panel");
|
||||
return !!panel?.shadowRoot;
|
||||
});
|
||||
}
|
||||
|
||||
const PANEL_EVAL = "window._ha=document.querySelector('home-assistant');window._main=_ha.shadowRoot.querySelector('home-assistant-main');window._drawer=_main.shadowRoot.querySelector('ha-drawer');window._resolver=_drawer.querySelector('partial-panel-resolver');window._custom=_resolver.querySelector('ha-panel-custom');window._panel=_custom.querySelector('maintenance-supporter-panel');window._sr=_panel.shadowRoot;";
|
||||
|
||||
export async function openEditDialog(page, entryId, task) {
|
||||
await page.evaluate(({ eid, task, fn }) => {
|
||||
eval(fn);
|
||||
window._sr.querySelector("maintenance-task-dialog").openEdit(eid, task);
|
||||
}, { eid: entryId, task, fn: PANEL_EVAL });
|
||||
await page.waitForTimeout(1500);
|
||||
}
|
||||
|
||||
export async function readDialogSelects(page) {
|
||||
return page.evaluate(({ fn }) => {
|
||||
eval(fn);
|
||||
const dlgSr = window._sr?.querySelector("maintenance-task-dialog")?.shadowRoot;
|
||||
if (!dlgSr) return [];
|
||||
return [...dlgSr.querySelectorAll("select")].map((sel) => {
|
||||
const label = sel.closest(".select-row")?.querySelector("label")?.textContent?.trim() || "unknown";
|
||||
const selected = [...sel.options].find((o) => o.selected);
|
||||
return { label, value: sel.value, selectedValue: selected?.value, match: sel.value === selected?.value };
|
||||
});
|
||||
}, { fn: PANEL_EVAL });
|
||||
}
|
||||
|
||||
export async function readDialogFields(page) {
|
||||
return page.evaluate(({ fn }) => {
|
||||
eval(fn);
|
||||
const dlgSr = window._sr?.querySelector("maintenance-task-dialog")?.shadowRoot;
|
||||
if (!dlgSr) return [];
|
||||
return [...dlgSr.querySelectorAll("ha-textfield")].map((f) => ({
|
||||
label: f.getAttribute("label"), type: f.getAttribute("type"), value: f.value,
|
||||
}));
|
||||
}, { fn: PANEL_EVAL });
|
||||
}
|
||||
|
||||
export async function cleanup(browser, ctx) {
|
||||
try { await ctx.close(); } catch { /* ignore */ }
|
||||
try { await browser.close(); } catch { /* ignore */ }
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* E2E test scenarios for issues #30 and #31.
|
||||
*
|
||||
* #30: Task without last_performed should anchor next_due on created_at,
|
||||
* not "today" every refresh.
|
||||
* #31: Reset prompt strings should make it clear that the date is the
|
||||
* "last performed" date, not the next due date.
|
||||
*
|
||||
* Usage:
|
||||
* docker compose up -d homeassistant-dev playwright
|
||||
* docker restart playwright-server
|
||||
* cd custom_components/maintenance_supporter/frontend-src
|
||||
* node e2e-issues-30-31.mjs
|
||||
*/
|
||||
import { setup, ws, cleanup } from "./e2e-helpers.mjs";
|
||||
import { readFile } from "fs/promises";
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function check(name, ok, detail) {
|
||||
if (ok) { passed++; console.log(" PASS " + name); }
|
||||
else { failed++; console.log(" FAIL " + name + (detail ? " — " + detail : "")); }
|
||||
}
|
||||
async function runTest(name, fn) {
|
||||
console.log("\n=== " + name + " ===");
|
||||
try { await fn(); }
|
||||
catch (e) { failed++; console.log(" ERROR: " + e.message + (e.stack ? "\n" + e.stack : "")); }
|
||||
}
|
||||
|
||||
const { browser, ctx, page } = await setup();
|
||||
const RUN = `${Date.now().toString(36)}`;
|
||||
|
||||
// Helper: create a temp object + task on a fresh entry, return ids.
|
||||
async function wsLog(cmd) {
|
||||
try {
|
||||
return await ws(page, cmd);
|
||||
} catch (e) {
|
||||
const detail = e.message + " · cmd=" + JSON.stringify(cmd);
|
||||
throw new Error(detail);
|
||||
}
|
||||
}
|
||||
async function createScenarioObject(name, taskOptions) {
|
||||
const created = await wsLog({
|
||||
type: "maintenance_supporter/object/create", name,
|
||||
});
|
||||
const entryId = created.entry_id;
|
||||
const taskRes = await wsLog({
|
||||
type: "maintenance_supporter/task/create",
|
||||
entry_id: entryId,
|
||||
...taskOptions,
|
||||
});
|
||||
return { entryId, taskId: taskRes.task_id };
|
||||
}
|
||||
async function readTask(entryId, taskId) {
|
||||
const obj = await ws(page, { type: "maintenance_supporter/object", entry_id: entryId });
|
||||
return obj.tasks.find(t => t.id === taskId);
|
||||
}
|
||||
async function deleteEntry(entryId) {
|
||||
try {
|
||||
await page.evaluate(async (eid) => {
|
||||
const ha = document.querySelector("home-assistant");
|
||||
const conn = ha.__hass.connection;
|
||||
await conn.sendMessagePromise({
|
||||
type: "config_entries/remove",
|
||||
entry_id: eid,
|
||||
});
|
||||
}, entryId);
|
||||
} catch (e) {
|
||||
console.log(" cleanup: ", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// SCENARIO #30: created_at fallback for tasks without last_performed
|
||||
// =====================================================================
|
||||
|
||||
await runTest("#30 — Task ohne last_performed bekommt created_at=today", async () => {
|
||||
const { entryId, taskId } = await createScenarioObject(`E2E Issue30 A ${RUN}`, {
|
||||
name: "Daily Task", task_type: "cleaning",
|
||||
schedule_type: "time_based", interval_days: 1, warning_days: 0, enabled: true,
|
||||
});
|
||||
const task = await readTask(entryId, taskId);
|
||||
console.log(` debug: next_due=${task.next_due} days_until_due=${task.days_until_due} last_performed=${task.last_performed}`);
|
||||
// The frontend doesn't expose created_at, but we know that with last_performed=null
|
||||
// and a fresh anchor, days_until_due must equal interval_days (1 here).
|
||||
check("days_until_due equals interval_days", task.days_until_due === 1,
|
||||
`got ${task.days_until_due} (expected 1)`);
|
||||
check("status is OK with warning_days=0", task.status === "ok",
|
||||
`got ${task.status}`);
|
||||
|
||||
await deleteEntry(entryId);
|
||||
});
|
||||
|
||||
await runTest("#30 — Interval=30 ergibt days_until_due=30", async () => {
|
||||
const { entryId, taskId } = await createScenarioObject(`E2E Issue30 B ${RUN}`, {
|
||||
name: "Monthly Task", task_type: "cleaning",
|
||||
schedule_type: "time_based", interval_days: 30, warning_days: 7, enabled: true,
|
||||
});
|
||||
const task = await readTask(entryId, taskId);
|
||||
console.log(` debug: next_due=${task.next_due} days_until_due=${task.days_until_due}`);
|
||||
check("days_until_due equals interval_days (30)", task.days_until_due === 30,
|
||||
`got ${task.days_until_due}`);
|
||||
check("status is OK (not DUE_SOON)", task.status === "ok",
|
||||
`got ${task.status}`);
|
||||
|
||||
await deleteEntry(entryId);
|
||||
});
|
||||
|
||||
await runTest("#30 — Refresh ändert next_due nicht (Anker stabil)", async () => {
|
||||
const { entryId, taskId } = await createScenarioObject(`E2E Issue30 C ${RUN}`, {
|
||||
name: "Stable Date", task_type: "cleaning",
|
||||
schedule_type: "time_based", interval_days: 7, enabled: true,
|
||||
});
|
||||
const t1 = await readTask(entryId, taskId);
|
||||
// Force a refresh by sleeping briefly
|
||||
await page.waitForTimeout(1500);
|
||||
const t2 = await readTask(entryId, taskId);
|
||||
check("next_due stable across refreshes", t1.next_due === t2.next_due,
|
||||
`t1=${t1.next_due}, t2=${t2.next_due}`);
|
||||
|
||||
await deleteEntry(entryId);
|
||||
});
|
||||
|
||||
await runTest("#30 — Task mit last_performed nutzt last_performed (nicht created_at)", async () => {
|
||||
const past = new Date(Date.now() - 60 * 86400000).toISOString().slice(0, 10);
|
||||
const { entryId, taskId } = await createScenarioObject(`E2E Issue30 D ${RUN}`, {
|
||||
name: "Past LP Task", task_type: "cleaning",
|
||||
schedule_type: "time_based", interval_days: 30,
|
||||
last_performed: past, enabled: true,
|
||||
});
|
||||
const task = await readTask(entryId, taskId);
|
||||
// last_performed=60 days ago + interval=30 → next_due was 30 days ago → OVERDUE
|
||||
check("status is OVERDUE", task.status === "overdue", `got ${task.status}`);
|
||||
check("days_until_due negative", task.days_until_due < 0, `got ${task.days_until_due}`);
|
||||
|
||||
await deleteEntry(entryId);
|
||||
});
|
||||
|
||||
// =====================================================================
|
||||
// SCENARIO #31: Reset prompt strings clearer
|
||||
// =====================================================================
|
||||
|
||||
await runTest("#31 — Reset-Prompt Label sagt 'Last performed date'", async () => {
|
||||
// Create a task to reset
|
||||
const { entryId, taskId } = await createScenarioObject(`E2E Issue31 ${RUN}`, {
|
||||
name: "Reset Demo", task_type: "cleaning",
|
||||
schedule_type: "time_based", interval_days: 30, enabled: true,
|
||||
});
|
||||
await page.goto("http://homeassistant-dev:8123/maintenance-supporter");
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Verify shipped strings via the built JS file (runs on host fs, not browser).
|
||||
const builtJs = await readFile(
|
||||
new URL("../frontend/maintenance-panel.js", import.meta.url),
|
||||
"utf-8",
|
||||
);
|
||||
check("EN reset_date_optional shipped",
|
||||
builtJs.includes("Last performed date (optional, defaults to today)"));
|
||||
check("EN reset_date_prompt shipped",
|
||||
builtJs.includes("Mark task as performed?"));
|
||||
check("DE reset_date_optional shipped",
|
||||
builtJs.includes("Letztes Erledigungs-Datum"));
|
||||
check("Old confusing string removed",
|
||||
!builtJs.includes("Reset this task?"),
|
||||
"old 'Reset this task?' still present");
|
||||
|
||||
// Reset via WS using the new label semantics: setting the date sets last_performed.
|
||||
const resetDate = "2025-01-15";
|
||||
await ws(page, {
|
||||
type: "maintenance_supporter/task/reset", entry_id: entryId, task_id: taskId,
|
||||
date: resetDate,
|
||||
});
|
||||
const task = await readTask(entryId, taskId);
|
||||
check("reset sets last_performed", task.last_performed === resetDate,
|
||||
`got ${task.last_performed}`);
|
||||
// next_due = last_performed + interval = 2025-01-15 + 30 = 2025-02-14
|
||||
check("next_due = last_performed + interval", task.next_due === "2025-02-14",
|
||||
`got ${task.next_due}`);
|
||||
|
||||
await deleteEntry(entryId);
|
||||
});
|
||||
|
||||
// =====================================================================
|
||||
// EDGE CASES: TZ + lifecycle interaction
|
||||
// =====================================================================
|
||||
|
||||
await runTest("EDGE — Lifecycle: create → complete → next_due aktualisiert", async () => {
|
||||
const { entryId, taskId } = await createScenarioObject(`E2E Edge LC ${RUN}`, {
|
||||
name: "Lifecycle Task", task_type: "cleaning",
|
||||
schedule_type: "time_based", interval_days: 30, warning_days: 7, enabled: true,
|
||||
});
|
||||
// Initial state: no last_performed, days_until_due == interval_days
|
||||
const t1 = await readTask(entryId, taskId);
|
||||
check("initial days_until_due == interval_days", t1.days_until_due === 30,
|
||||
`got ${t1.days_until_due}`);
|
||||
check("initial last_performed is null", t1.last_performed === null,
|
||||
`got ${t1.last_performed}`);
|
||||
|
||||
// Complete via WS
|
||||
await ws(page, {
|
||||
type: "maintenance_supporter/task/complete",
|
||||
entry_id: entryId, task_id: taskId,
|
||||
});
|
||||
const t2 = await readTask(entryId, taskId);
|
||||
check("after complete: last_performed set", t2.last_performed !== null,
|
||||
`got ${t2.last_performed}`);
|
||||
check("after complete: days_until_due == interval", t2.days_until_due === 30,
|
||||
`got ${t2.days_until_due}`);
|
||||
// History entry written
|
||||
check("after complete: history has entry", t2.history.length === 1,
|
||||
`len=${t2.history.length}`);
|
||||
// Timestamp must be TZ-aware
|
||||
const ts = t2.history[0].timestamp;
|
||||
check("history timestamp has TZ suffix",
|
||||
/[+-]\d{2}:\d{2}$|Z$/.test(ts),
|
||||
`ts=${ts}`);
|
||||
|
||||
await deleteEntry(entryId);
|
||||
});
|
||||
|
||||
await runTest("EDGE — Reset zu vergangenem Datum: last_performed + history", async () => {
|
||||
const { entryId, taskId } = await createScenarioObject(`E2E Edge RST ${RUN}`, {
|
||||
name: "Reset Edge", task_type: "cleaning",
|
||||
schedule_type: "time_based", interval_days: 30, enabled: true,
|
||||
});
|
||||
const past = "2025-01-15";
|
||||
await ws(page, {
|
||||
type: "maintenance_supporter/task/reset",
|
||||
entry_id: entryId, task_id: taskId, date: past,
|
||||
});
|
||||
const t = await readTask(entryId, taskId);
|
||||
check("reset: last_performed = past date", t.last_performed === past,
|
||||
`got ${t.last_performed}`);
|
||||
check("reset: next_due = past + interval (overdue)",
|
||||
t.next_due === "2025-02-14",
|
||||
`got ${t.next_due}`);
|
||||
check("reset: status is overdue", t.status === "overdue",
|
||||
`got ${t.status}`);
|
||||
check("reset: history has reset entry",
|
||||
t.history.some(h => h.type === "reset"),
|
||||
`types=${t.history.map(h => h.type).join(",")}`);
|
||||
|
||||
await deleteEntry(entryId);
|
||||
});
|
||||
|
||||
await runTest("EDGE — Skip via WS: history entry + last_performed bumped", async () => {
|
||||
const { entryId, taskId } = await createScenarioObject(`E2E Edge SKP ${RUN}`, {
|
||||
name: "Skip Edge", task_type: "cleaning",
|
||||
schedule_type: "time_based", interval_days: 14, enabled: true,
|
||||
});
|
||||
await ws(page, {
|
||||
type: "maintenance_supporter/task/skip",
|
||||
entry_id: entryId, task_id: taskId, reason: "user busy",
|
||||
});
|
||||
const t = await readTask(entryId, taskId);
|
||||
check("skip: last_performed set (today)", t.last_performed !== null);
|
||||
check("skip: history has skip entry",
|
||||
t.history.some(h => h.type === "skipped"),
|
||||
`types=${t.history.map(h => h.type).join(",")}`);
|
||||
check("skip: next_due == today + interval (14)",
|
||||
t.days_until_due === 14,
|
||||
`got ${t.days_until_due}`);
|
||||
|
||||
await deleteEntry(entryId);
|
||||
});
|
||||
|
||||
await runTest("EDGE — Update last_performed setzt next_due neu", async () => {
|
||||
const { entryId, taskId } = await createScenarioObject(`E2E Edge UPD ${RUN}`, {
|
||||
name: "Update Edge", task_type: "cleaning",
|
||||
schedule_type: "time_based", interval_days: 30, enabled: true,
|
||||
});
|
||||
const past = "2024-06-01";
|
||||
await ws(page, {
|
||||
type: "maintenance_supporter/task/update",
|
||||
entry_id: entryId, task_id: taskId,
|
||||
last_performed: past,
|
||||
});
|
||||
const t = await readTask(entryId, taskId);
|
||||
check("update: last_performed reflected", t.last_performed === past,
|
||||
`got ${t.last_performed}`);
|
||||
check("update: next_due = last + interval",
|
||||
t.next_due === "2024-07-01",
|
||||
`got ${t.next_due}`);
|
||||
|
||||
await deleteEntry(entryId);
|
||||
});
|
||||
|
||||
// =====================================================================
|
||||
await cleanup(browser, ctx);
|
||||
console.log("\n" + "═".repeat(50));
|
||||
console.log(`RESULTS: ${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) { console.log("SOME TESTS FAILED!"); process.exit(1); }
|
||||
else console.log("ALL TESTS PASSED!");
|
||||
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* E2E tests for features that previously had WS endpoints without UI.
|
||||
*
|
||||
* Usage:
|
||||
* docker compose up -d homeassistant-dev playwright
|
||||
* docker restart playwright-server
|
||||
* cd custom_components/maintenance_supporter/frontend-src
|
||||
* node e2e-new-features.mjs
|
||||
*/
|
||||
import { setup, ws, cleanup } from "./e2e-helpers.mjs";
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function check(name, ok, detail) {
|
||||
if (ok) { passed++; console.log(" PASS " + name); }
|
||||
else { failed++; console.log(" FAIL " + name + (detail ? " — " + detail : "")); }
|
||||
}
|
||||
async function runTest(name, fn) {
|
||||
console.log("\n=== " + name + " ===");
|
||||
try { await fn(); }
|
||||
catch (e) { failed++; console.log(" ERROR: " + e.message); }
|
||||
}
|
||||
|
||||
const { browser, ctx, page } = await setup();
|
||||
|
||||
// =====================================================================
|
||||
// Batch A: Test-Notification Button in Settings
|
||||
// =====================================================================
|
||||
|
||||
await runTest("Test-Notification: WS endpoint reachable", async () => {
|
||||
const res = await ws(page, { type: "maintenance_supporter/global/test_notification" });
|
||||
check("WS response has success field", typeof res.success === "boolean",
|
||||
`got ${JSON.stringify(res)}`);
|
||||
// When no notify_service is configured the backend returns success=false
|
||||
// with a "no_service" message. Either outcome is a valid response —
|
||||
// what matters is that the endpoint is reachable.
|
||||
check("WS response has message field", typeof res.message === "string" || res.success,
|
||||
`got ${JSON.stringify(res)}`);
|
||||
});
|
||||
|
||||
await runTest("Test-Notification: Button renders in Settings view", async () => {
|
||||
// Navigate to maintenance panel and open settings. In the base panel the
|
||||
// settings tab is not always pre-rendered; we verify the string keys exist
|
||||
// in the bundled JS so the button can be rendered when the tab is opened.
|
||||
const { readFile } = await import("fs/promises");
|
||||
const js = await readFile(
|
||||
new URL("../frontend/maintenance-panel.js", import.meta.url),
|
||||
"utf-8",
|
||||
);
|
||||
check("EN send_test string shipped", js.includes("Send test"));
|
||||
check("EN test_notification string shipped", js.includes("Test notification"));
|
||||
check("DE test_notification string shipped", js.includes("Test-Benachrichtigung"));
|
||||
check("testing state string shipped",
|
||||
js.includes("Sending\\u2026") || js.includes("Sending…"));
|
||||
});
|
||||
|
||||
// =====================================================================
|
||||
// Batch B: Re-analyze Button in Task Detail view
|
||||
// =====================================================================
|
||||
|
||||
await runTest("Re-analyze: WS endpoint returns analysis", async () => {
|
||||
const objs = await ws(page, { type: "maintenance_supporter/objects" });
|
||||
const obj = objs.objects[0];
|
||||
const t1 = obj.tasks[0];
|
||||
if (!t1) { console.log(" SKIP: no task in first object"); return; }
|
||||
|
||||
const res = await ws(page, {
|
||||
type: "maintenance_supporter/task/analyze_interval",
|
||||
entry_id: obj.entry_id, task_id: t1.id,
|
||||
});
|
||||
check("analysis has current_interval field",
|
||||
"current_interval" in res, `got keys=${Object.keys(res).join(",")}`);
|
||||
check("analysis has confidence field", typeof res.confidence === "string");
|
||||
check("analysis has data_points number", typeof res.data_points === "number");
|
||||
});
|
||||
|
||||
await runTest("Re-analyze: Button strings shipped", async () => {
|
||||
const { readFile } = await import("fs/promises");
|
||||
const js = await readFile(
|
||||
new URL("../frontend/maintenance-panel.js", import.meta.url),
|
||||
"utf-8",
|
||||
);
|
||||
check("EN reanalyze label shipped", js.includes("Re-analyze"));
|
||||
check("DE reanalyze label shipped", js.includes("Neu analysieren"));
|
||||
check("reanalyze_result string shipped", js.includes("New analysis"));
|
||||
check("reanalyze_insufficient_data shipped",
|
||||
js.includes("Not enough data to produce"));
|
||||
});
|
||||
|
||||
// =====================================================================
|
||||
// Batch B.2: Environmental Entity Selector (Task Dialog, sensor_based)
|
||||
// =====================================================================
|
||||
|
||||
await runTest("Environmental entity: WS endpoint persists adaptive_config", async () => {
|
||||
// Create a sensor-based task, then set and clear the env entity.
|
||||
const obj = await ws(page, { type: "maintenance_supporter/object/create", name: `env ${Date.now().toString(36)}` });
|
||||
const task = await ws(page, {
|
||||
type: "maintenance_supporter/task/create",
|
||||
entry_id: obj.entry_id, name: "env-task",
|
||||
schedule_type: "sensor_based", interval_days: 30,
|
||||
trigger_config: { type: "threshold", entity_id: "sensor.dummy", trigger_above: 10 },
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
// Set
|
||||
const r1 = await ws(page, {
|
||||
type: "maintenance_supporter/task/set_environmental_entity",
|
||||
entry_id: obj.entry_id, task_id: task.task_id,
|
||||
environmental_entity: "sensor.outdoor_temperature",
|
||||
environmental_attribute: null,
|
||||
});
|
||||
check("set succeeds", r1.success === true, JSON.stringify(r1));
|
||||
|
||||
const detail = await ws(page, {
|
||||
type: "maintenance_supporter/object", entry_id: obj.entry_id,
|
||||
});
|
||||
const t = detail.tasks.find(x => x.id === task.task_id);
|
||||
check("adaptive_config reflects env entity",
|
||||
t?.adaptive_config?.environmental_entity === "sensor.outdoor_temperature",
|
||||
`got ${JSON.stringify(t?.adaptive_config)}`);
|
||||
|
||||
// Clear
|
||||
await ws(page, {
|
||||
type: "maintenance_supporter/task/set_environmental_entity",
|
||||
entry_id: obj.entry_id, task_id: task.task_id,
|
||||
environmental_entity: null,
|
||||
});
|
||||
|
||||
// Cleanup (as admin via config_entries)
|
||||
try {
|
||||
await page.evaluate(async (eid) => {
|
||||
const ha = document.querySelector("home-assistant");
|
||||
await ha.hass.connection.sendMessagePromise({
|
||||
type: "config_entries/remove", entry_id: eid,
|
||||
});
|
||||
}, obj.entry_id);
|
||||
} catch { /* best-effort */ }
|
||||
});
|
||||
|
||||
await runTest("Environmental entity: dialog strings shipped", async () => {
|
||||
const { readFile } = await import("fs/promises");
|
||||
const js = await readFile(
|
||||
new URL("../frontend/maintenance-panel.js", import.meta.url),
|
||||
"utf-8",
|
||||
);
|
||||
check("EN environmental_entity label", js.includes("Environmental sensor"));
|
||||
check("DE environmental_entity label", js.includes("Umgebungs-Sensor"));
|
||||
check("helper text shipped", js.includes("adjusts the interval"));
|
||||
});
|
||||
|
||||
// =====================================================================
|
||||
// Batch C: Seasonal Overrides Dialog
|
||||
// =====================================================================
|
||||
|
||||
await runTest("Seasonal overrides: WS roundtrip set + clear", async () => {
|
||||
const obj = await ws(page, { type: "maintenance_supporter/object/create", name: `seas ${Date.now().toString(36)}` });
|
||||
const task = await ws(page, {
|
||||
type: "maintenance_supporter/task/create",
|
||||
entry_id: obj.entry_id, name: "seas-task",
|
||||
schedule_type: "time_based", interval_days: 30, enabled: true,
|
||||
});
|
||||
|
||||
// Set overrides for Jan + Jul
|
||||
const r1 = await ws(page, {
|
||||
type: "maintenance_supporter/task/seasonal_overrides",
|
||||
entry_id: obj.entry_id, task_id: task.task_id,
|
||||
overrides: { 1: 2.0, 7: 0.5 },
|
||||
});
|
||||
check("set succeeds", r1.success === true, JSON.stringify(r1));
|
||||
|
||||
const detail = await ws(page, {
|
||||
type: "maintenance_supporter/object", entry_id: obj.entry_id,
|
||||
});
|
||||
const t = detail.tasks.find(x => x.id === task.task_id);
|
||||
const ov = t?.adaptive_config?.seasonal_overrides;
|
||||
check("adaptive_config has overrides",
|
||||
ov && (ov["1"] === 2.0 || ov[1] === 2.0) && (ov["7"] === 0.5 || ov[7] === 0.5),
|
||||
`got ${JSON.stringify(ov)}`);
|
||||
|
||||
// Clear
|
||||
const r2 = await ws(page, {
|
||||
type: "maintenance_supporter/task/seasonal_overrides",
|
||||
entry_id: obj.entry_id, task_id: task.task_id,
|
||||
overrides: {},
|
||||
});
|
||||
check("clear succeeds", r2.success === true);
|
||||
|
||||
try {
|
||||
await page.evaluate(async (eid) => {
|
||||
const ha = document.querySelector("home-assistant");
|
||||
await ha.hass.connection.sendMessagePromise({
|
||||
type: "config_entries/remove", entry_id: eid,
|
||||
});
|
||||
}, obj.entry_id);
|
||||
} catch { /* best-effort */ }
|
||||
});
|
||||
|
||||
await runTest("Seasonal overrides: dialog + button strings shipped", async () => {
|
||||
const { readFile } = await import("fs/promises");
|
||||
const js = await readFile(
|
||||
new URL("../frontend/maintenance-panel.js", import.meta.url),
|
||||
"utf-8",
|
||||
);
|
||||
check("EN edit button", js.includes("Edit seasonal factors"));
|
||||
check("EN title", js.includes("Seasonal factors (override)"));
|
||||
check("clear_all string", js.includes("Clear all"));
|
||||
check("DE title", js.includes("Saisonale Faktoren"));
|
||||
});
|
||||
|
||||
// =====================================================================
|
||||
// Batch D: Groups CRUD UI
|
||||
// =====================================================================
|
||||
|
||||
await runTest("Groups: WS roundtrip create + update + delete", async () => {
|
||||
// Need an object + task to reference
|
||||
const obj = await ws(page, { type: "maintenance_supporter/object/create", name: `grp-obj ${Date.now().toString(36)}` });
|
||||
const task = await ws(page, {
|
||||
type: "maintenance_supporter/task/create",
|
||||
entry_id: obj.entry_id, name: "grp-task",
|
||||
schedule_type: "time_based", interval_days: 30, enabled: true,
|
||||
});
|
||||
|
||||
// Create group
|
||||
const create = await ws(page, {
|
||||
type: "maintenance_supporter/group/create",
|
||||
name: "E2E Group",
|
||||
description: "created by e2e",
|
||||
task_refs: [{ entry_id: obj.entry_id, task_id: task.task_id }],
|
||||
});
|
||||
check("group/create returns group_id", typeof create.group_id === "string",
|
||||
JSON.stringify(create));
|
||||
const gid = create.group_id;
|
||||
|
||||
// Read back
|
||||
const list = await ws(page, { type: "maintenance_supporter/groups" });
|
||||
check("group appears in list", !!list.groups[gid],
|
||||
`got ${Object.keys(list.groups).join(",")}`);
|
||||
check("task_refs persisted",
|
||||
list.groups[gid].task_refs.length === 1,
|
||||
JSON.stringify(list.groups[gid]));
|
||||
|
||||
// Update
|
||||
const update = await ws(page, {
|
||||
type: "maintenance_supporter/group/update",
|
||||
group_id: gid,
|
||||
name: "E2E Group renamed",
|
||||
task_refs: [],
|
||||
});
|
||||
check("group/update succeeds", update.success === true);
|
||||
|
||||
const list2 = await ws(page, { type: "maintenance_supporter/groups" });
|
||||
check("group renamed", list2.groups[gid].name === "E2E Group renamed");
|
||||
check("task_refs cleared", list2.groups[gid].task_refs.length === 0);
|
||||
|
||||
// Delete
|
||||
const del = await ws(page, {
|
||||
type: "maintenance_supporter/group/delete", group_id: gid,
|
||||
});
|
||||
check("group/delete succeeds", del.success === true);
|
||||
|
||||
const list3 = await ws(page, { type: "maintenance_supporter/groups" });
|
||||
check("group gone", !list3.groups[gid]);
|
||||
|
||||
// Cleanup object
|
||||
try {
|
||||
await page.evaluate(async (eid) => {
|
||||
const ha = document.querySelector("home-assistant");
|
||||
await ha.hass.connection.sendMessagePromise({
|
||||
type: "config_entries/remove", entry_id: eid,
|
||||
});
|
||||
}, obj.entry_id);
|
||||
} catch { /* best-effort */ }
|
||||
});
|
||||
|
||||
await runTest("Groups: dialog + buttons strings shipped", async () => {
|
||||
const { readFile } = await import("fs/promises");
|
||||
const js = await readFile(
|
||||
new URL("../frontend/maintenance-panel.js", import.meta.url),
|
||||
"utf-8",
|
||||
);
|
||||
check("EN new_group", js.includes("New group"));
|
||||
check("EN edit_group", js.includes("Edit group"));
|
||||
check("EN no_groups", js.includes("No groups yet"));
|
||||
check("EN group_select_tasks", js.includes("Select tasks"));
|
||||
check("DE new_group", js.includes("Neue Gruppe"));
|
||||
});
|
||||
|
||||
// =====================================================================
|
||||
await cleanup(browser, ctx);
|
||||
console.log("\n" + "═".repeat(50));
|
||||
console.log(`RESULTS: ${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) { console.log("SOME TESTS FAILED!"); process.exit(1); }
|
||||
else console.log("ALL TESTS PASSED!");
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* E2E for the Uhrzeit / time-of-day scheduling feature.
|
||||
*
|
||||
* Verifies:
|
||||
* 1. Feature flag off by default → dialog has NO time input → WS summary has
|
||||
* schedule_time=null.
|
||||
* 2. Toggling the flag on → dialog shows the field → a value round-trips to
|
||||
* WS (create + /object) and back to the dialog in edit mode.
|
||||
* 3. Feature flag off again → WS-computed _status ignores the stored time
|
||||
* (backend gating confirms "OFF means midnight semantic").
|
||||
*/
|
||||
import { setup, ws, cleanup } from "./e2e-helpers.mjs";
|
||||
|
||||
const RUN = Date.now().toString(36);
|
||||
const OBJ_NAME = `Schedule Time ${RUN}`;
|
||||
|
||||
const log = (...a) => console.log(...a);
|
||||
let pass = 0, fail = 0;
|
||||
const check = (label, ok, detail) => {
|
||||
if (ok) { pass++; log(` ✓ ${label}`); }
|
||||
else { fail++; log(` ✗ ${label}${detail ? " — " + detail : ""}`); }
|
||||
};
|
||||
|
||||
const PANEL_EVAL = `
|
||||
window._ha=document.querySelector('home-assistant');
|
||||
window._main=_ha.shadowRoot.querySelector('home-assistant-main');
|
||||
window._drawer=_main.shadowRoot.querySelector('ha-drawer');
|
||||
window._resolver=_drawer.querySelector('partial-panel-resolver');
|
||||
window._custom=_resolver.querySelector('ha-panel-custom');
|
||||
window._panel=_custom.querySelector('maintenance-supporter-panel');
|
||||
window._sr=_panel.shadowRoot;
|
||||
`;
|
||||
|
||||
async function main() {
|
||||
const { browser, ctx, page } = await setup({ mobile: false });
|
||||
|
||||
log("\n== Step 1: feature flag starts OFF");
|
||||
let settings = await ws(page, {
|
||||
type: "maintenance_supporter/global/update",
|
||||
settings: { advanced_schedule_time_visible: false },
|
||||
});
|
||||
check("backend reports schedule_time=false", settings.features.schedule_time === false);
|
||||
// Force panel to pick up the flag change (direct WS doesn't trigger listener).
|
||||
await page.evaluate((fn) => { eval(fn); return window._panel._loadData(); }, PANEL_EVAL);
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
log("\n== Step 2: create obj + time-based task");
|
||||
const obj = await ws(page, { type: "maintenance_supporter/object/create", name: OBJ_NAME });
|
||||
const taskRes = await ws(page, {
|
||||
type: "maintenance_supporter/task/create",
|
||||
entry_id: obj.entry_id, name: "Daily chore", interval_days: 1,
|
||||
});
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
log("\n== Step 3: open edit dialog, field should NOT be visible");
|
||||
const task = (await ws(page, { type: "maintenance_supporter/object", entry_id: obj.entry_id }))
|
||||
.tasks.find(t => t.id === taskRes.task_id);
|
||||
await page.evaluate(({ eid, task, fn }) => {
|
||||
eval(fn);
|
||||
window._sr.querySelector("maintenance-task-dialog").openEdit(eid, task);
|
||||
}, { eid: obj.entry_id, task, fn: PANEL_EVAL });
|
||||
await page.waitForTimeout(1200);
|
||||
|
||||
const hiddenState = await page.evaluate((fn) => {
|
||||
eval(fn);
|
||||
const dlgSr = window._sr.querySelector("maintenance-task-dialog").shadowRoot;
|
||||
return {
|
||||
scheduleTimeEnabled: window._sr.querySelector("maintenance-task-dialog").scheduleTimeEnabled,
|
||||
hasTimeInput: !!dlgSr.querySelector('ha-textfield[type="time"]'),
|
||||
};
|
||||
}, PANEL_EVAL);
|
||||
check("scheduleTimeEnabled prop is false on dialog", hiddenState.scheduleTimeEnabled === false);
|
||||
check("time input is NOT in the DOM when feature is off", hiddenState.hasTimeInput === false);
|
||||
|
||||
log("\n== Step 4: enable the feature flag, re-open edit");
|
||||
settings = await ws(page, {
|
||||
type: "maintenance_supporter/global/update",
|
||||
settings: { advanced_schedule_time_visible: true },
|
||||
});
|
||||
check("backend reports schedule_time=true after toggle", settings.features.schedule_time === true);
|
||||
await page.waitForTimeout(1000);
|
||||
// Direct WS update doesn't fire the panel's "settings-changed" listener,
|
||||
// so force a reload so _features picks up the flip.
|
||||
await page.evaluate((fn) => {
|
||||
eval(fn);
|
||||
return window._panel._loadData();
|
||||
}, PANEL_EVAL);
|
||||
await page.waitForTimeout(1500);
|
||||
// Close dialog, reopen (the prop is bound from _features on the parent panel)
|
||||
await page.evaluate((fn) => {
|
||||
eval(fn);
|
||||
window._sr.querySelector("maintenance-task-dialog")._open = false;
|
||||
}, PANEL_EVAL);
|
||||
await page.waitForTimeout(500);
|
||||
await page.evaluate(({ eid, task, fn }) => {
|
||||
eval(fn);
|
||||
window._sr.querySelector("maintenance-task-dialog").openEdit(eid, task);
|
||||
}, { eid: obj.entry_id, task, fn: PANEL_EVAL });
|
||||
await page.waitForTimeout(1200);
|
||||
const visibleState = await page.evaluate((fn) => {
|
||||
eval(fn);
|
||||
const dlgSr = window._sr.querySelector("maintenance-task-dialog").shadowRoot;
|
||||
return {
|
||||
scheduleTimeEnabled: window._sr.querySelector("maintenance-task-dialog").scheduleTimeEnabled,
|
||||
hasTimeInput: !!dlgSr.querySelector('ha-textfield[type="time"]'),
|
||||
};
|
||||
}, PANEL_EVAL);
|
||||
check("scheduleTimeEnabled prop is true after feature on", visibleState.scheduleTimeEnabled === true);
|
||||
check("time input IS in the DOM after feature on", visibleState.hasTimeInput === true);
|
||||
|
||||
log("\n== Step 5: set time via WS (skip the DOM event roundtrip), re-read task");
|
||||
await ws(page, {
|
||||
type: "maintenance_supporter/task/update",
|
||||
entry_id: obj.entry_id, task_id: taskRes.task_id, schedule_time: "14:00",
|
||||
});
|
||||
await page.waitForTimeout(1500);
|
||||
const updated = (await ws(page, { type: "maintenance_supporter/object", entry_id: obj.entry_id }))
|
||||
.tasks.find(t => t.id === taskRes.task_id);
|
||||
check("summary contains schedule_time=14:00", updated.schedule_time === "14:00",
|
||||
`got ${updated.schedule_time}`);
|
||||
|
||||
log("\n== Step 6: reject malformed schedule_time at the WS layer");
|
||||
let rejected = false;
|
||||
try {
|
||||
await ws(page, {
|
||||
type: "maintenance_supporter/task/update",
|
||||
entry_id: obj.entry_id, task_id: taskRes.task_id, schedule_time: "25:99",
|
||||
});
|
||||
} catch {
|
||||
rejected = true;
|
||||
}
|
||||
check("malformed HH:MM rejected by schema", rejected);
|
||||
|
||||
log("\n== Step 7: cleanup");
|
||||
await ws(page, { type: "maintenance_supporter/object/delete", entry_id: obj.entry_id });
|
||||
// Leave the flag on — test fixture state is not restored, next run will re-toggle.
|
||||
await cleanup(browser, ctx);
|
||||
|
||||
log(`\n=== ${pass} passed, ${fail} failed ===`);
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Comprehensive E2E test suite for the Maintenance Supporter panel.
|
||||
*
|
||||
* Prerequisites:
|
||||
* docker compose up -d # ha-maint + playwright
|
||||
* docker restart playwright-server # clean session
|
||||
*
|
||||
* Usage:
|
||||
* cd custom_components/maintenance_supporter/frontend-src
|
||||
* node e2e-test.mjs # auto-fetches refresh token
|
||||
* HA_REFRESH_TOKEN=... node e2e-test.mjs # explicit token
|
||||
*/
|
||||
import { setup, ws, openEditDialog, readDialogSelects, readDialogFields, cleanup } from "./e2e-helpers.mjs";
|
||||
|
||||
let passed = 0, failed = 0, skipped = 0;
|
||||
function check(name, ok) {
|
||||
if (ok) { passed++; console.log(" PASS " + name); }
|
||||
else { failed++; console.log(" FAIL " + name); }
|
||||
}
|
||||
|
||||
async function runTest(name, fn) {
|
||||
console.log("\n=== " + name + " ===");
|
||||
try { await fn(); }
|
||||
catch (e) { failed++; console.log(" ERROR: " + e.message); }
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// DESKTOP TESTS
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
const { browser, ctx, page } = await setup();
|
||||
const ENTRY = (await ws(page, { type: "maintenance_supporter/objects" })).objects[0].entry_id;
|
||||
|
||||
await runTest("Sort dropdown exists with 4 modes", async () => {
|
||||
const selects = await page.evaluate(() => {
|
||||
const ha = document.querySelector("home-assistant");
|
||||
const main = ha?.shadowRoot?.querySelector("home-assistant-main");
|
||||
const drawer = main?.shadowRoot?.querySelector("ha-drawer");
|
||||
const resolver = drawer?.querySelector("partial-panel-resolver");
|
||||
const custom = resolver?.querySelector("ha-panel-custom");
|
||||
const panel = custom?.querySelector("maintenance-supporter-panel");
|
||||
const sr = panel?.shadowRoot;
|
||||
const filterBar = sr?.querySelector(".filter-bar");
|
||||
if (!filterBar) return [];
|
||||
return [...filterBar.querySelectorAll("select")].map(s => ({
|
||||
options: [...s.options].map(o => o.value),
|
||||
}));
|
||||
});
|
||||
const sortSelect = selects.find(s => s.options.includes("due_date"));
|
||||
check("sort dropdown found", !!sortSelect);
|
||||
check("has 4 sort options", sortSelect?.options.length === 4);
|
||||
});
|
||||
|
||||
await runTest("All Objects view via KPI click", async () => {
|
||||
const clicked = await page.evaluate(() => {
|
||||
const ha = document.querySelector("home-assistant");
|
||||
const sr = ha?.shadowRoot?.querySelector("home-assistant-main")?.shadowRoot?.querySelector("ha-drawer")?.querySelector("partial-panel-resolver")?.querySelector("ha-panel-custom")?.querySelector("maintenance-supporter-panel")?.shadowRoot;
|
||||
const item = sr?.querySelector(".stat-item.clickable");
|
||||
if (item) { item.click(); return true; }
|
||||
return false;
|
||||
});
|
||||
check("KPI clickable", clicked);
|
||||
await page.waitForTimeout(1000);
|
||||
const cards = await page.evaluate(() => {
|
||||
const ha = document.querySelector("home-assistant");
|
||||
const sr = ha?.shadowRoot?.querySelector("home-assistant-main")?.shadowRoot?.querySelector("ha-drawer")?.querySelector("partial-panel-resolver")?.querySelector("ha-panel-custom")?.querySelector("maintenance-supporter-panel")?.shadowRoot;
|
||||
return sr?.querySelectorAll(".object-card")?.length ?? 0;
|
||||
});
|
||||
check("object cards rendered", cards > 0);
|
||||
});
|
||||
|
||||
// Reload panel to reset view state after All Objects test
|
||||
await page.goto("http://homeassistant-dev:8123/maintenance-supporter");
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
await runTest("Select dropdowns preserve values on edit", async () => {
|
||||
const objs = await ws(page, { type: "maintenance_supporter/objects" });
|
||||
let triggerTask = null, triggerEntry = null;
|
||||
for (const o of objs.objects) {
|
||||
for (const t of o.tasks) {
|
||||
if (t.trigger_config?.type) { triggerTask = t; triggerEntry = o.entry_id; break; }
|
||||
}
|
||||
if (triggerTask) break;
|
||||
}
|
||||
if (!triggerTask) { skipped++; console.log(" SKIP: no sensor-trigger task"); return; }
|
||||
await openEditDialog(page, triggerEntry, triggerTask);
|
||||
const selects = await readDialogSelects(page);
|
||||
for (const s of selects) {
|
||||
check(s.label + " selected matches value", s.match);
|
||||
}
|
||||
await page.keyboard.press("Escape");
|
||||
});
|
||||
|
||||
await runTest("Last performed field in task dialog", async () => {
|
||||
const cr = await ws(page, {
|
||||
type: "maintenance_supporter/task/create", entry_id: ENTRY,
|
||||
name: "E2E LP Test", task_type: "service", schedule_type: "time_based",
|
||||
interval_days: 30, last_performed: "2025-11-01", enabled: true,
|
||||
});
|
||||
// Reload so panel picks up the new task
|
||||
await page.goto("http://homeassistant-dev:8123/maintenance-supporter");
|
||||
await page.waitForTimeout(5000);
|
||||
const obj = await ws(page, { type: "maintenance_supporter/object", entry_id: ENTRY });
|
||||
const task = obj.tasks.find(t => t.id === cr.task_id);
|
||||
await openEditDialog(page, ENTRY, task);
|
||||
const fields = await readDialogFields(page);
|
||||
console.log(" Fields found:", fields.length, fields.map(f => f.type + ":" + f.label?.slice(0, 20)).join(", "));
|
||||
const dateField = fields.find(f => f.type === "date");
|
||||
check("date field exists", !!dateField);
|
||||
check("date value correct", dateField?.value === "2025-11-01");
|
||||
await page.keyboard.press("Escape");
|
||||
await ws(page, { type: "maintenance_supporter/task/delete", entry_id: ENTRY, task_id: cr.task_id });
|
||||
});
|
||||
|
||||
await runTest("Task create with enabled field (Bug #14 regression)", async () => {
|
||||
const r = await ws(page, {
|
||||
type: "maintenance_supporter/task/create", entry_id: ENTRY,
|
||||
name: "E2E Enabled Test", task_type: "cleaning", schedule_type: "time_based",
|
||||
interval_days: 30, enabled: true,
|
||||
});
|
||||
check("create with enabled succeeds", !!r.task_id);
|
||||
if (r.task_id) await ws(page, { type: "maintenance_supporter/task/delete", entry_id: ENTRY, task_id: r.task_id });
|
||||
});
|
||||
|
||||
await runTest("Reset with date field (Bug #12 regression)", async () => {
|
||||
const objs = await ws(page, { type: "maintenance_supporter/objects" });
|
||||
const t = objs.objects[0].tasks[0];
|
||||
try {
|
||||
await ws(page, { type: "maintenance_supporter/task/reset", entry_id: ENTRY, task_id: t.id, date: "2026-04-01" });
|
||||
check("reset with date succeeds", true);
|
||||
} catch (e) {
|
||||
check("reset with date succeeds", false);
|
||||
}
|
||||
});
|
||||
|
||||
await runTest("Desktop header bar hidden on overview", async () => {
|
||||
await page.goto("http://homeassistant-dev:8123/maintenance-supporter");
|
||||
await page.waitForTimeout(3000);
|
||||
const headerVisible = await page.evaluate(() => {
|
||||
const ha = document.querySelector("home-assistant");
|
||||
const sr = ha?.shadowRoot?.querySelector("home-assistant-main")?.shadowRoot?.querySelector("ha-drawer")?.querySelector("partial-panel-resolver")?.querySelector("ha-panel-custom")?.querySelector("maintenance-supporter-panel")?.shadowRoot;
|
||||
const header = sr?.querySelector(".header");
|
||||
if (!header) return false;
|
||||
const style = window.getComputedStyle(header);
|
||||
return style.display !== "none" && header.offsetHeight > 0;
|
||||
});
|
||||
check("header NOT visible on desktop overview", !headerVisible);
|
||||
});
|
||||
|
||||
await cleanup(browser, ctx);
|
||||
|
||||
// Reconnect for timezone + mobile tests
|
||||
import { chromium } from "playwright";
|
||||
const browser2 = await chromium.connect("ws://localhost:3000");
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// TIMEZONE + MOBILE TESTS (reuse same browser connection)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
await runTest("Date formatting in US timezone (Bug #21)", async () => {
|
||||
const tzCtx = await browser2.newContext({ viewport: { width: 800, height: 600 }, locale: "en-US", timezoneId: "America/New_York" });
|
||||
const tzPage = await tzCtx.newPage();
|
||||
await tzPage.goto("about:blank");
|
||||
const r = await tzPage.evaluate(() => ({
|
||||
utc: new Date("2025-11-01").toLocaleDateString("en-US"),
|
||||
local: new Date("2025-11-01T00:00:00").toLocaleDateString("en-US"),
|
||||
}));
|
||||
check("UTC parse shows wrong date (Oct 31)", r.utc.includes("10/31"));
|
||||
check("Local parse shows correct date (Nov)", r.local.includes("11/"));
|
||||
await tzCtx.close();
|
||||
});
|
||||
|
||||
await runTest("Mobile hamburger menu button", async () => {
|
||||
const mobCtx = await browser2.newContext({ viewport: { width: 375, height: 812 }, locale: "en-US", colorScheme: "dark", isMobile: true });
|
||||
const mobPage = await mobCtx.newPage();
|
||||
await mobPage.goto("http://homeassistant-dev:8123");
|
||||
await mobPage.waitForTimeout(1000);
|
||||
await mobPage.evaluate((r) => localStorage.setItem("hassTokens", JSON.stringify({
|
||||
hassUrl: "http://homeassistant-dev:8123", clientId: "http://homeassistant-dev:8123/",
|
||||
refresh_token: r, access_token: "", token_type: "Bearer", expires_in: 1800, expires: 0,
|
||||
})), process.env.HA_REFRESH_TOKEN);
|
||||
await mobPage.goto("http://homeassistant-dev:8123/maintenance-supporter");
|
||||
await mobPage.waitForTimeout(7000);
|
||||
const hasMenu = await mobPage.evaluate(() => {
|
||||
const ha = document.querySelector("home-assistant");
|
||||
const sr = ha?.shadowRoot?.querySelector("home-assistant-main")?.shadowRoot?.querySelector("ha-drawer")?.querySelector("partial-panel-resolver")?.querySelector("ha-panel-custom")?.querySelector("maintenance-supporter-panel")?.shadowRoot;
|
||||
return !!sr?.querySelector("ha-menu-button");
|
||||
});
|
||||
check("ha-menu-button visible on mobile", hasMenu);
|
||||
await mobCtx.close();
|
||||
});
|
||||
|
||||
await cleanup(browser2, { close() {} });
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
console.log("\n" + "═".repeat(50));
|
||||
console.log(`RESULTS: ${passed} passed, ${failed} failed, ${skipped} skipped`);
|
||||
if (failed > 0) { console.log("SOME TESTS FAILED!"); process.exit(1); }
|
||||
else console.log("ALL TESTS PASSED!");
|
||||
@@ -0,0 +1,93 @@
|
||||
import { build } from "esbuild";
|
||||
import { cpSync, mkdirSync, rmSync } from "fs";
|
||||
|
||||
// Content-hashed chunk names change every time their inputs change; esbuild
|
||||
// writes the new files but never removes the old ones. Clear the chunks dir up
|
||||
// front so stale, unreferenced chunks don't accumulate in git (they did before
|
||||
// this — every frontend change left orphans behind).
|
||||
rmSync("../frontend/strategy/chunks", { recursive: true, force: true });
|
||||
|
||||
const common = {
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
target: "es2021",
|
||||
minify: true,
|
||||
sourcemap: false,
|
||||
external: [],
|
||||
};
|
||||
|
||||
// Panel
|
||||
await build({
|
||||
...common,
|
||||
entryPoints: ["maintenance-panel.ts"],
|
||||
outfile: "../frontend/maintenance-panel.js",
|
||||
});
|
||||
|
||||
// Lovelace Card
|
||||
await build({
|
||||
...common,
|
||||
entryPoints: ["maintenance-card.ts"],
|
||||
outfile: "../frontend/maintenance-card.js",
|
||||
});
|
||||
|
||||
// Dashboard Strategy (HA 2026.5+ — silent no-op on older HA)
|
||||
//
|
||||
// v2.3.4 (issue #52): we MUST split dynamic imports out into separate
|
||||
// chunks. HA's strategy loader has a 5 s timeout on
|
||||
// customElements.whenDefined("ll-strategy-dashboard-..."); if the strategy
|
||||
// bundle takes longer than that to download + parse + execute, the
|
||||
// dashboard fails to open. Without splitting, esbuild inlines every
|
||||
// dynamic import — strategy file ballooned to 510 KB pulling in 7 dialog
|
||||
// components + 3 section cards. With splitting enabled, the strategy
|
||||
// entry stays small (~13 KB) and registers customElements instantly;
|
||||
// heavy components load lazily as separate chunks only when they're
|
||||
// actually rendered.
|
||||
//
|
||||
// All output lands in ../frontend/strategy/ so HA can serve the directory
|
||||
// as a single static-path mount and the entry's relative ./chunks/ imports
|
||||
// resolve naturally without per-chunk URL registration.
|
||||
await build({
|
||||
...common,
|
||||
entryPoints: ["maintenance-dashboard-strategy.ts"],
|
||||
outdir: "../frontend/strategy",
|
||||
splitting: true,
|
||||
entryNames: "[name]",
|
||||
chunkNames: "chunks/[name]-[hash]",
|
||||
});
|
||||
|
||||
// Dashboard-strategy registration shim (v2.8.1 — strategy timeout fix).
|
||||
//
|
||||
// HA loads frontend_extra_module_url entries as fire-and-forget import() with
|
||||
// no ordering/await, then only waits ~5 s via whenDefined for a custom: strategy
|
||||
// element. Under heavy HACS plugin load the full strategy bundle could lose that
|
||||
// race (element downloaded but not yet executed). This tiny zero-import shim is
|
||||
// what we register as the extra_module_url: it defines the dashboard tag
|
||||
// synchronously (a few hundred bytes, no import graph) and lazy-import()s the
|
||||
// heavy bundle above only on first generate()/getConfigElement().
|
||||
await build({
|
||||
...common,
|
||||
entryPoints: ["maintenance-strategy-shim.ts"],
|
||||
outfile: "../frontend/maintenance-strategy-shim.js",
|
||||
});
|
||||
|
||||
// Calendar Card (v1.9.0+ — Lovelace card extracted from panel's Calendar tab)
|
||||
await build({
|
||||
...common,
|
||||
entryPoints: ["maintenance-calendar-card.ts"],
|
||||
outfile: "../frontend/maintenance-calendar-card.js",
|
||||
});
|
||||
|
||||
// Copy the runtime-loaded locale tables into the served frontend dir. Only EN
|
||||
// is bundled (imported by styles.ts); the other languages are fetched at
|
||||
// runtime from frontend/locales/, so editing a translation needs no rebuild.
|
||||
mkdirSync("../frontend/locales", { recursive: true });
|
||||
cpSync("locales", "../frontend/locales", { recursive: true });
|
||||
|
||||
// v2.21: vendor pdf.js for the work sheet's inline manual excerpt. Copied,
|
||||
// not bundled — the work-sheet page loads it on demand from VENDOR_URL, so
|
||||
// the panel/card bundles stay untouched.
|
||||
mkdirSync("../frontend/vendor", { recursive: true });
|
||||
cpSync("node_modules/pdfjs-dist/legacy/build/pdf.min.mjs", "../frontend/vendor/pdf.min.mjs");
|
||||
cpSync("node_modules/pdfjs-dist/legacy/build/pdf.worker.min.mjs", "../frontend/vendor/pdf.worker.min.mjs");
|
||||
|
||||
console.log("Build complete.");
|
||||
@@ -0,0 +1,387 @@
|
||||
/**
|
||||
* Pure helper for the panel's Calendar tab (v1.5.0+).
|
||||
*
|
||||
* Given the loaded objects, a window in days, and an optional user filter,
|
||||
* produce a flat day-bucketed event list ready for rendering.
|
||||
*
|
||||
* Recurring projection (time-based tasks): the first occurrence is the task's
|
||||
* next_due (or "today" if overdue/triggered). Subsequent occurrences are
|
||||
* projected by adding interval_days repeatedly until the window end, capped
|
||||
* at MAX_OCCURRENCES_PER_TASK to prevent absurdly small intervals (e.g. a
|
||||
* 1-day-interval task in a 30-day window would otherwise produce 30 entries).
|
||||
*
|
||||
* Sensor-triggered tasks: only next_due is shown — we have no honest way to
|
||||
* predict when a sensor will next fire.
|
||||
*/
|
||||
|
||||
import type { MaintenanceObjectResponse } from "../types";
|
||||
import { intervalSpanDays } from "./interval";
|
||||
|
||||
export const MAX_OCCURRENCES_PER_TASK = 5;
|
||||
|
||||
export interface CalendarEvent {
|
||||
/** ISO date string (YYYY-MM-DD) — the day this event is bucketed under. */
|
||||
date: string;
|
||||
entry_id: string;
|
||||
task_id: string;
|
||||
task_name: string;
|
||||
object_name: string;
|
||||
status: string; // "ok" | "due_soon" | "overdue" | "triggered"
|
||||
days_until_due: number | null;
|
||||
/** True if this is a projected recurrence (not the actual next_due). */
|
||||
projected: boolean;
|
||||
schedule_type: string;
|
||||
interval_days: number | null;
|
||||
/** v2.6.3 (#59): interval unit so the "every N …" label + projection match. */
|
||||
interval_unit?: string | null;
|
||||
responsible_user_id: string | null;
|
||||
avg_cost: number | null;
|
||||
/** v1.5.1: source indicator. */
|
||||
adaptive_enabled: boolean;
|
||||
/** v1.5.1: sensor prediction confidence ("low" | "medium" | "high") or null
|
||||
* for time-based / no prediction available. */
|
||||
prediction_confidence: string | null;
|
||||
/** v2.2.0 — for past-window events, the original ISO timestamp of the
|
||||
* underlying history entry. The history-edit WS uses this as the stable
|
||||
* identifier (see ws_update_history_entry / original_timestamp). null
|
||||
* for forward-projected / next_due events. */
|
||||
history_timestamp?: string | null;
|
||||
/** v2.2.0 — history entry type for past events
|
||||
* ("completed" | "reset" | "skipped" | "triggered"). null for future. */
|
||||
history_type?: string | null;
|
||||
/** v2.2.0 — original cost / notes / duration from the history entry,
|
||||
* so the past-events list shows what really happened (not avg_cost). */
|
||||
history_cost?: number | null;
|
||||
history_notes?: string | null;
|
||||
history_duration?: number | null;
|
||||
}
|
||||
|
||||
export interface CalendarDayBucket {
|
||||
date: string; // ISO date YYYY-MM-DD
|
||||
events: CalendarEvent[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a Date as ISO YYYY-MM-DD using LOCAL time components — matches what
|
||||
* the user sees on their wall clock, not UTC. The naive `.toISOString().slice(0,10)`
|
||||
* approach silently shifts dates near midnight in non-UTC timezones.
|
||||
*/
|
||||
export function isoDateLocal(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
/** Build the list of N consecutive ISO dates starting today (local). */
|
||||
export function buildWindowDates(today: Date, windowDays: number): string[] {
|
||||
const out: string[] = [];
|
||||
for (let i = 0; i < windowDays; i++) {
|
||||
const d = new Date(today);
|
||||
d.setDate(d.getDate() + i);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
out.push(isoDateLocal(d));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Add `days` to an ISO date (local), return new ISO date. */
|
||||
function addDaysIso(iso: string, days: number): string {
|
||||
const [y, m, d] = iso.split("-").map(Number);
|
||||
const date = new Date(y, m - 1, d);
|
||||
date.setDate(date.getDate() + days);
|
||||
return isoDateLocal(date);
|
||||
}
|
||||
|
||||
/** Average recorded cost across history rows that HAVE a cost (mean of
|
||||
* non-null `cost` values). Note this differs from the panel KPI, which divides
|
||||
* total_cost by times_performed — completions without a recorded cost are
|
||||
* counted differently. This mean is used only for the calendar tooltip. */
|
||||
function computeAvgCost(history: Array<{ cost?: number | null }> | undefined): number | null {
|
||||
if (!history || history.length === 0) return null;
|
||||
const costs = history.map((h) => h.cost).filter((c): c is number => typeof c === "number");
|
||||
if (costs.length === 0) return null;
|
||||
return costs.reduce((a, b) => a + b, 0) / costs.length;
|
||||
}
|
||||
|
||||
interface ProjectionInput {
|
||||
windowStart: string;
|
||||
windowEnd: string; // inclusive
|
||||
task: any; // panel's task shape from MaintenanceObjectResponse
|
||||
entryId: string;
|
||||
objectName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Project up to MAX_OCCURRENCES_PER_TASK occurrences for a single task.
|
||||
* Returns events keyed by their bucketed date.
|
||||
*/
|
||||
function projectTask(input: ProjectionInput): CalendarEvent[] {
|
||||
const { windowStart, windowEnd, task, entryId, objectName } = input;
|
||||
const out: CalendarEvent[] = [];
|
||||
|
||||
const baseEvent = (date: string, projected: boolean): CalendarEvent => ({
|
||||
date,
|
||||
entry_id: entryId,
|
||||
task_id: task.id,
|
||||
task_name: task.name,
|
||||
object_name: objectName,
|
||||
// Projected recurrences are hypothetical future occurrences that assume
|
||||
// the current cycle resolves on schedule. If the parent task is currently
|
||||
// overdue/triggered, carrying that status forward to the projection (e.g.
|
||||
// "OVERDUE 211d" on the May 7 projection of a 7-day-interval task) is
|
||||
// misleading — the projection IS the assumption that the user completes
|
||||
// it today, so the projected slot should read as a fresh "ok" event.
|
||||
status: projected && (task.status === "overdue" || task.status === "triggered")
|
||||
? "ok"
|
||||
: task.status,
|
||||
days_until_due: projected ? null : (task.days_until_due ?? null),
|
||||
projected,
|
||||
schedule_type: task.schedule_type,
|
||||
interval_days: task.interval_days ?? null,
|
||||
interval_unit: task.interval_unit ?? null,
|
||||
responsible_user_id: task.responsible_user_id ?? null,
|
||||
avg_cost: computeAvgCost(task.history),
|
||||
adaptive_enabled: !!task.adaptive_config?.enabled,
|
||||
prediction_confidence: task.threshold_prediction_confidence ?? null,
|
||||
});
|
||||
|
||||
// Step between projected occurrences, unit-aware (issue #59): a weeks/months/
|
||||
// years interval must advance by its real day-span, not the raw count (else a
|
||||
// "1 year" task projects at 1-day steps and spams the window).
|
||||
const stepDays = Math.max(
|
||||
1, Math.round(intervalSpanDays(task.interval_days, task.interval_unit)),
|
||||
);
|
||||
|
||||
// Overdue / triggered → bucket on today (windowStart) regardless of next_due
|
||||
if (task.status === "overdue" || task.status === "triggered") {
|
||||
out.push(baseEvent(windowStart, false));
|
||||
// Continue projecting from "today" forward for time-based tasks
|
||||
if (task.schedule_type === "time_based" && task.interval_days && task.interval_days > 0) {
|
||||
let cursor = addDaysIso(windowStart, stepDays);
|
||||
let count = 1;
|
||||
while (cursor <= windowEnd && count < MAX_OCCURRENCES_PER_TASK) {
|
||||
out.push(baseEvent(cursor, true));
|
||||
count++;
|
||||
cursor = addDaysIso(cursor, stepDays);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Non-actionable status (ok / due_soon): need a next_due
|
||||
const nextDue = task.next_due;
|
||||
if (typeof nextDue !== "string" || !nextDue) return out;
|
||||
const firstDate = nextDue.slice(0, 10); // strip time portion if any
|
||||
|
||||
// First occurrence must be in window
|
||||
if (firstDate >= windowStart && firstDate <= windowEnd) {
|
||||
out.push(baseEvent(firstDate, false));
|
||||
} else if (firstDate > windowEnd) {
|
||||
return out; // entire occurrence chain is past window
|
||||
}
|
||||
|
||||
// Subsequent projected occurrences (time-based only, with interval_days)
|
||||
if (task.schedule_type === "time_based" && task.interval_days && task.interval_days > 0) {
|
||||
let cursor = addDaysIso(firstDate, stepDays);
|
||||
let count = out.length;
|
||||
while (cursor <= windowEnd && count < MAX_OCCURRENCES_PER_TASK) {
|
||||
// Skip occurrences before window start (when next_due itself was past)
|
||||
if (cursor >= windowStart) {
|
||||
out.push(baseEvent(cursor, true));
|
||||
count++;
|
||||
}
|
||||
cursor = addDaysIso(cursor, stepDays);
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Status sort priority: lower = shown first. */
|
||||
const STATUS_RANK: Record<string, number> = {
|
||||
overdue: 0,
|
||||
triggered: 1,
|
||||
due_soon: 2,
|
||||
ok: 3,
|
||||
};
|
||||
|
||||
/**
|
||||
* Main entry point: build the day-bucketed event list for the Calendar tab.
|
||||
*
|
||||
* @param objects The panel's loaded objects (with nested tasks).
|
||||
* @param today "Today" as a Date (caller passes new Date() — easier for tests).
|
||||
* @param windowDays Number of days to include (7 / 14 / 30).
|
||||
* @param userFilter null/empty = all; otherwise filter tasks by responsible_user_id.
|
||||
*/
|
||||
export function buildCalendarBuckets(
|
||||
objects: MaintenanceObjectResponse[],
|
||||
today: Date,
|
||||
windowDays: number,
|
||||
userFilter: string | null = null,
|
||||
): CalendarDayBucket[] {
|
||||
const days = buildWindowDates(today, windowDays);
|
||||
const windowStart = days[0];
|
||||
const windowEnd = days[days.length - 1];
|
||||
|
||||
const allEvents: CalendarEvent[] = [];
|
||||
for (const obj of objects) {
|
||||
const objectName = obj.object?.name || "";
|
||||
const entryId = obj.entry_id;
|
||||
const tasks = obj.tasks || [];
|
||||
for (const task of tasks) {
|
||||
// User filter
|
||||
if (userFilter && task.responsible_user_id !== userFilter) continue;
|
||||
// Disabled tasks never produce calendar events
|
||||
if (task.enabled === false) continue;
|
||||
const projected = projectTask({
|
||||
windowStart, windowEnd, task, entryId, objectName,
|
||||
});
|
||||
allEvents.push(...projected);
|
||||
}
|
||||
}
|
||||
|
||||
// Bucket by date
|
||||
const byDate = new Map<string, CalendarEvent[]>();
|
||||
for (const day of days) byDate.set(day, []);
|
||||
for (const ev of allEvents) {
|
||||
const bucket = byDate.get(ev.date);
|
||||
if (bucket) bucket.push(ev);
|
||||
}
|
||||
|
||||
// Sort within day: status priority first, then projected last, then by name
|
||||
for (const [, evs] of byDate) {
|
||||
evs.sort((a, b) => {
|
||||
const rA = STATUS_RANK[a.status] ?? 99;
|
||||
const rB = STATUS_RANK[b.status] ?? 99;
|
||||
if (rA !== rB) return rA - rB;
|
||||
if (a.projected !== b.projected) return a.projected ? 1 : -1;
|
||||
const cmp = a.object_name.localeCompare(b.object_name);
|
||||
if (cmp !== 0) return cmp;
|
||||
return a.task_name.localeCompare(b.task_name);
|
||||
});
|
||||
}
|
||||
|
||||
return days.map((d) => ({ date: d, events: byDate.get(d) ?? [] }));
|
||||
}
|
||||
|
||||
// ── v2.2.0 — past-window bucketer ──────────────────────────────────────────
|
||||
//
|
||||
// Same shape as buildCalendarBuckets but walks the task HISTORY (not next_due)
|
||||
// and buckets entries by their actual completion/reset/skip/trigger date.
|
||||
// Used by the calendar card's past-mode chip ("← 30 d") to surface
|
||||
// recently-performed maintenance for review or correction.
|
||||
//
|
||||
// Window: from (today − pastDays + 1) to today, inclusive — so passing 30
|
||||
// gives 30 day-buckets ending today.
|
||||
|
||||
const HISTORY_TYPE_TO_STATUS: Record<string, string> = {
|
||||
completed: "ok",
|
||||
reset: "ok",
|
||||
skipped: "due_soon",
|
||||
triggered: "triggered",
|
||||
trigger_replaced: "triggered",
|
||||
trigger_removed: "ok", // config change, not a due/triggered event
|
||||
};
|
||||
|
||||
interface HistoryEntryShape {
|
||||
timestamp?: string;
|
||||
type?: string;
|
||||
notes?: string;
|
||||
cost?: number;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
/** Build a list of N consecutive ISO dates ending today (local). */
|
||||
export function buildPastWindowDates(today: Date, pastDays: number): string[] {
|
||||
const out: string[] = [];
|
||||
for (let i = pastDays - 1; i >= 0; i--) {
|
||||
const d = new Date(today);
|
||||
d.setDate(d.getDate() - i);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
out.push(isoDateLocal(d));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build day-bucketed events for the *past* N days using task history.
|
||||
*
|
||||
* Each history entry whose timestamp falls inside the window becomes one
|
||||
* CalendarEvent with `history_timestamp` set — the calendar card uses that
|
||||
* timestamp as the stable key when dispatching an edit-history click event.
|
||||
*/
|
||||
export function buildPastBuckets(
|
||||
objects: MaintenanceObjectResponse[],
|
||||
today: Date,
|
||||
pastDays: number,
|
||||
userFilter: string | null = null,
|
||||
): CalendarDayBucket[] {
|
||||
const days = buildPastWindowDates(today, pastDays);
|
||||
const windowStart = days[0];
|
||||
const windowEnd = days[days.length - 1];
|
||||
|
||||
const byDate = new Map<string, CalendarEvent[]>();
|
||||
for (const day of days) byDate.set(day, []);
|
||||
|
||||
for (const obj of objects) {
|
||||
const objectName = obj.object?.name || "";
|
||||
const entryId = obj.entry_id;
|
||||
const tasks = obj.tasks || [];
|
||||
for (const task of tasks) {
|
||||
if (userFilter && task.responsible_user_id !== userFilter) continue;
|
||||
const history = (task.history || []) as HistoryEntryShape[];
|
||||
for (const h of history) {
|
||||
if (typeof h?.timestamp !== "string") continue;
|
||||
const dateKey = h.timestamp.slice(0, 10); // YYYY-MM-DD
|
||||
if (dateKey < windowStart || dateKey > windowEnd) continue;
|
||||
const bucket = byDate.get(dateKey);
|
||||
if (!bucket) continue;
|
||||
const evType = h.type ?? "completed";
|
||||
bucket.push({
|
||||
date: dateKey,
|
||||
entry_id: entryId,
|
||||
task_id: task.id,
|
||||
task_name: task.name,
|
||||
object_name: objectName,
|
||||
status: HISTORY_TYPE_TO_STATUS[evType] ?? "ok",
|
||||
days_until_due: null,
|
||||
projected: false,
|
||||
schedule_type: task.schedule_type,
|
||||
interval_days: task.interval_days ?? null,
|
||||
responsible_user_id: task.responsible_user_id ?? null,
|
||||
avg_cost: typeof h.cost === "number" ? h.cost : null,
|
||||
adaptive_enabled: !!task.adaptive_config?.enabled,
|
||||
prediction_confidence: null,
|
||||
history_timestamp: h.timestamp,
|
||||
history_type: evType,
|
||||
history_cost: typeof h.cost === "number" ? h.cost : null,
|
||||
history_notes: typeof h.notes === "string" ? h.notes : null,
|
||||
history_duration: typeof h.duration === "number" ? h.duration : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort within day: type priority (completed first, triggered last) then by
|
||||
// object/task name. Past events are never "projected" so no opacity dimming.
|
||||
const PAST_TYPE_RANK: Record<string, number> = {
|
||||
completed: 0,
|
||||
reset: 1,
|
||||
skipped: 2,
|
||||
triggered: 3,
|
||||
trigger_replaced: 4,
|
||||
};
|
||||
for (const [, evs] of byDate) {
|
||||
evs.sort((a, b) => {
|
||||
const rA = PAST_TYPE_RANK[a.history_type ?? ""] ?? 99;
|
||||
const rB = PAST_TYPE_RANK[b.history_type ?? ""] ?? 99;
|
||||
if (rA !== rB) return rA - rB;
|
||||
const cmp = a.object_name.localeCompare(b.object_name);
|
||||
if (cmp !== 0) return cmp;
|
||||
return a.task_name.localeCompare(b.task_name);
|
||||
});
|
||||
}
|
||||
|
||||
return days.map((d) => ({ date: d, events: byDate.get(d) ?? [] }));
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Download a text payload as a file — Companion-app safe.
|
||||
*
|
||||
* The naive pattern (a detached `<a download>`, `a.click()`, then an immediate
|
||||
* `URL.revokeObjectURL`) silently fails inside the Home Assistant Companion
|
||||
* app's WebView (especially iOS WKWebView): the anchor must be in the DOM,
|
||||
* carry `target="_blank"` so the app's download handler engages, and the
|
||||
* object URL must NOT be revoked before the (asynchronous) download has
|
||||
* started. This mirrors home-assistant-frontend's own `fileDownload` helper.
|
||||
*/
|
||||
export function downloadTextFile(
|
||||
content: string,
|
||||
filename: string,
|
||||
mime: string,
|
||||
): void {
|
||||
const blob = new Blob([content], { type: mime });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.target = "_blank";
|
||||
a.rel = "noopener";
|
||||
a.style.display = "none";
|
||||
document.body.appendChild(a);
|
||||
a.dispatchEvent(new MouseEvent("click"));
|
||||
document.body.removeChild(a);
|
||||
// Revoke late: slower WebViews (the Companion app) kick off the download
|
||||
// asynchronously, and revoking the blob URL immediately cancels it.
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a (same-origin) URL as a file — Companion-app safe.
|
||||
*
|
||||
* For binary blobs served by our authenticated view: fetch a signed path via
|
||||
* `auth/sign_path` first, then hand the signed URL here. Same DOM-anchored,
|
||||
* `target="_blank"` trick as {@link downloadTextFile} so the Companion WebView
|
||||
* engages its download handler; no blob URL to revoke.
|
||||
*/
|
||||
export function downloadUrl(url: string, filename: string): void {
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.target = "_blank";
|
||||
a.rel = "noopener";
|
||||
a.style.display = "none";
|
||||
document.body.appendChild(a);
|
||||
a.dispatchEvent(new MouseEvent("click"));
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/** Human-readable byte size (B / KB / MB) for document storage figures. */
|
||||
export function formatBytes(bytes: number | undefined): string {
|
||||
const b = bytes ?? 0;
|
||||
if (b < 1024) return `${b} B`;
|
||||
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`;
|
||||
return `${(b / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Unit-aware interval math (issue #59) for the frontend's progress bars and
|
||||
* calendar projection stepping. Uses an AVERAGE day-span per unit (below), an
|
||||
* intentional approximation — the backend `helpers/dates.add_interval` is
|
||||
* calendar-exact (Jan+1mo = 31 days), so display-side spans can differ by a day
|
||||
* or two. Cosmetic only. Pure functions — unit-tested in interval.test.ts.
|
||||
*/
|
||||
|
||||
/** Approximate (average) days per interval unit — mean Gregorian month / Julian
|
||||
* year. NOT calendar-exact; see the module note above. */
|
||||
export const UNIT_DAYS: Record<string, number> = {
|
||||
days: 1,
|
||||
weeks: 7,
|
||||
months: 30.4368,
|
||||
years: 365.25,
|
||||
};
|
||||
|
||||
/** Approximate length of `intervalDays` of `unit` in days; 0 when no interval. */
|
||||
export function intervalSpanDays(
|
||||
intervalDays: number | null | undefined,
|
||||
unit?: string | null,
|
||||
): number {
|
||||
if (!intervalDays || intervalDays <= 0) return 0;
|
||||
return intervalDays * (UNIT_DAYS[unit || "days"] ?? 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress through the current cycle, unit-aware.
|
||||
* Returns a clamped 0–100 % plus `overflow` (raw % > 100, i.e. past due).
|
||||
* Falls back to {0, false} when there is no usable interval/countdown.
|
||||
*/
|
||||
export function daysProgress(
|
||||
intervalDays: number | null | undefined,
|
||||
daysUntilDue: number | null | undefined,
|
||||
unit?: string | null,
|
||||
): { pct: number; overflow: boolean } {
|
||||
const span = intervalSpanDays(intervalDays, unit);
|
||||
if (span <= 0 || daysUntilDue == null) return { pct: 0, overflow: false };
|
||||
const raw = ((span - daysUntilDue) / span) * 100;
|
||||
return { pct: Math.max(0, Math.min(100, raw)), overflow: raw > 100 };
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/** (#67) Objects-table column catalog.
|
||||
*
|
||||
* Shared by the panel's table view and the Settings column-config UI. Keep
|
||||
* KNOWN/DEFAULT in lockstep with const.py (KNOWN_OBJECT_TABLE_COLUMNS /
|
||||
* DEFAULT_OBJECTS_TABLE_COLUMNS) — the backend sanitises to the same set.
|
||||
* Parity is enforced by tests/test_frontend_const_parity.py (drift fails CI).
|
||||
*/
|
||||
|
||||
export interface ObjectColumnDef {
|
||||
/** Stable column key (matches the persisted setting + object field). */
|
||||
key: string;
|
||||
/** i18n key for the header cell + the Settings checkbox label. */
|
||||
labelKey: string;
|
||||
/** Columns the user may not remove (always rendered). */
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
/** Canonical order + label mapping. Most labels reuse existing i18n keys. */
|
||||
export const OBJECT_COLUMNS: ObjectColumnDef[] = [
|
||||
{ key: "name", labelKey: "name", required: true },
|
||||
{ key: "manufacturer", labelKey: "manufacturer" },
|
||||
{ key: "model", labelKey: "model" },
|
||||
{ key: "serial_number", labelKey: "serial_number_label" },
|
||||
{ key: "installation_date", labelKey: "installed" },
|
||||
{ key: "warranty_expiry", labelKey: "warranty" },
|
||||
{ key: "area_id", labelKey: "area" },
|
||||
{ key: "documentation_url", labelKey: "documentation_url_label" },
|
||||
{ key: "notes", labelKey: "object_notes_label" },
|
||||
{ key: "task_count", labelKey: "tasks" },
|
||||
{ key: "actions", labelKey: "actions" },
|
||||
];
|
||||
|
||||
export const KNOWN_OBJECT_COLUMNS: string[] = OBJECT_COLUMNS.map((c) => c.key);
|
||||
|
||||
export const DEFAULT_OBJECTS_TABLE_COLUMNS: string[] = [
|
||||
"name",
|
||||
"manufacturer",
|
||||
"model",
|
||||
"serial_number",
|
||||
"installation_date",
|
||||
"warranty_expiry",
|
||||
"area_id",
|
||||
"task_count",
|
||||
"actions",
|
||||
];
|
||||
|
||||
/**
|
||||
* Sanitise a stored/configured column list to known keys (order preserved,
|
||||
* deduped). Falls back to the defaults when empty/invalid, and always keeps
|
||||
* the required `name` column so the table stays usable. Mirrors the backend.
|
||||
*/
|
||||
export function sanitizeColumns(cols: unknown): string[] {
|
||||
if (!Array.isArray(cols)) return [...DEFAULT_OBJECTS_TABLE_COLUMNS];
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const c of cols) {
|
||||
if (typeof c === "string" && KNOWN_OBJECT_COLUMNS.includes(c) && !seen.has(c)) {
|
||||
seen.add(c);
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
if (!out.length) return [...DEFAULT_OBJECTS_TABLE_COLUMNS];
|
||||
if (!out.includes("name")) out.unshift("name");
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/** Self-contained printable maintenance report for one object.
|
||||
*
|
||||
* Produces a full HTML document (inline print CSS) that the panel opens in a new
|
||||
* tab via a Blob URL — the user then prints or "Save as PDF" from the browser.
|
||||
* No PDF dependency, works offline. All user content is HTML-escaped.
|
||||
*/
|
||||
|
||||
import type { MaintenanceObject, MaintenanceTask } from "../types";
|
||||
|
||||
export interface ReportLabels {
|
||||
title: string;
|
||||
generated: string;
|
||||
manufacturer: string;
|
||||
model: string;
|
||||
serial: string;
|
||||
installed: string;
|
||||
warranty: string;
|
||||
area: string;
|
||||
notes: string;
|
||||
tasksHeading: string;
|
||||
colTask: string;
|
||||
colType: string;
|
||||
colStatus: string;
|
||||
colSchedule: string;
|
||||
colLastDone: string;
|
||||
colNextDue: string;
|
||||
colCost: string;
|
||||
colTimes: string;
|
||||
totalCost: string;
|
||||
/** Human schedule label for a task — pass formatRecurrence so calendar
|
||||
* kinds (weekdays/nth_weekday/…) render properly, not just intervals. */
|
||||
scheduleLabel: (t: MaintenanceTask) => string;
|
||||
statusLabel: (s: string) => string;
|
||||
typeLabel: (t: string) => string;
|
||||
none: string;
|
||||
}
|
||||
|
||||
function esc(v: unknown): string {
|
||||
return String(v ?? "").replace(/[&<>"']/g, (c) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c] as string));
|
||||
}
|
||||
|
||||
export function buildObjectReportHtml(
|
||||
obj: MaintenanceObject,
|
||||
tasks: MaintenanceTask[],
|
||||
labels: ReportLabels,
|
||||
fmtDate: (iso: string | null | undefined) => string,
|
||||
currencySymbol: string,
|
||||
nowIso: string,
|
||||
): string {
|
||||
const meta = ([
|
||||
[labels.manufacturer, obj.manufacturer],
|
||||
[labels.model, obj.model],
|
||||
[labels.serial, obj.serial_number],
|
||||
[labels.installed, obj.installation_date ? fmtDate(obj.installation_date) : null],
|
||||
[labels.warranty, obj.warranty_expiry ? fmtDate(obj.warranty_expiry) : null],
|
||||
] as Array<[string, string | null | undefined]>).filter(([, v]) => !!v);
|
||||
|
||||
const rows = tasks.map((t) => {
|
||||
const schedule = labels.scheduleLabel(t);
|
||||
return `<tr>
|
||||
<td>${esc(t.name)}</td>
|
||||
<td>${esc(labels.typeLabel(t.type))}</td>
|
||||
<td>${esc(labels.statusLabel(t.status))}</td>
|
||||
<td>${esc(schedule)}</td>
|
||||
<td>${esc(t.last_performed ? fmtDate(t.last_performed) : labels.none)}</td>
|
||||
<td>${esc(t.next_due ? fmtDate(t.next_due) : labels.none)}</td>
|
||||
<td class="num">${t.times_performed ?? 0}</td>
|
||||
<td class="num">${(t.total_cost ?? 0).toFixed(2)} ${esc(currencySymbol)}</td>
|
||||
</tr>`;
|
||||
}).join("");
|
||||
|
||||
const totalCost = tasks.reduce((n, t) => n + (t.total_cost ?? 0), 0);
|
||||
|
||||
return `<!DOCTYPE html><html><head><meta charset="utf-8">
|
||||
<title>${esc(labels.title)} — ${esc(obj.name)}</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { font: 13px/1.5 -apple-system, Segoe UI, Roboto, sans-serif; color: #1a1a1a; margin: 32px; }
|
||||
h1 { font-size: 22px; margin: 0 0 2px; }
|
||||
.sub { color: #666; margin: 0 0 20px; }
|
||||
.meta { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 6px 24px; margin-bottom: 20px; }
|
||||
.meta div { border-bottom: 1px solid #eee; padding: 4px 0; }
|
||||
.meta .k { color: #888; font-size: 11px; text-transform: uppercase; letter-spacing: .04em; }
|
||||
h2 { font-size: 15px; margin: 24px 0 8px; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { text-align: left; padding: 7px 8px; border-bottom: 1px solid #eee; vertical-align: top; }
|
||||
th { font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: #888; border-bottom: 2px solid #ccc; }
|
||||
td.num, th.num { text-align: right; }
|
||||
tfoot td { font-weight: 600; border-top: 2px solid #ccc; border-bottom: none; }
|
||||
.notes { margin-top: 16px; white-space: pre-wrap; color: #333; }
|
||||
@media print { body { margin: 0; } @page { margin: 16mm; } }
|
||||
</style></head><body>
|
||||
<h1>${esc(obj.name)}</h1>
|
||||
<p class="sub">${esc(labels.title)} · ${esc(labels.generated)}: ${esc(fmtDate(nowIso))}</p>
|
||||
${meta.length ? `<div class="meta">${meta.map(([k, v]) =>
|
||||
`<div><div class="k">${esc(k)}</div>${esc(v)}</div>`).join("")}</div>` : ""}
|
||||
<h2>${esc(labels.tasksHeading)} (${tasks.length})</h2>
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>${esc(labels.colTask)}</th><th>${esc(labels.colType)}</th><th>${esc(labels.colStatus)}</th>
|
||||
<th>${esc(labels.colSchedule)}</th><th>${esc(labels.colLastDone)}</th><th>${esc(labels.colNextDue)}</th>
|
||||
<th class="num">${esc(labels.colTimes)}</th><th class="num">${esc(labels.colCost)}</th>
|
||||
</tr></thead>
|
||||
<tbody>${rows || `<tr><td colspan="8">${esc(labels.none)}</td></tr>`}</tbody>
|
||||
<tfoot><tr><td colspan="7">${esc(labels.totalCost)}</td><td class="num">${totalCost.toFixed(2)} ${esc(currencySymbol)}</td></tr></tfoot>
|
||||
</table>
|
||||
${obj.notes ? `<div class="notes"><strong>${esc(labels.notes)}:</strong>\n${esc(obj.notes)}</div>` : ""}
|
||||
</body></html>`;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/** Windowing math for the virtualized dashboard task table (500+ tasks).
|
||||
*
|
||||
* Pure functions only — the panel measures the scroll container / row height
|
||||
* and renders `rows.slice(start, end)` between two grid-spanning spacers whose
|
||||
* heights keep the scrollbar honest. The window start/end snap to a `step`
|
||||
* grid so a 1-row scroll doesn't rebind the whole slice on every frame.
|
||||
*
|
||||
* Why windowing at all: the table's cross-row column alignment comes from CSS
|
||||
* subgrid, which is incompatible with `content-visibility`/`size` containment
|
||||
* (the cheap skip-offscreen-paint trick used elsewhere). So for large installs
|
||||
* the initial O(n) render is paid on every keystroke/filter change unless the
|
||||
* DOM itself is windowed.
|
||||
*/
|
||||
|
||||
/** Rows below this count render normally — windowing has bookkeeping cost and
|
||||
* only pays off when the O(n) render actually hurts. */
|
||||
export const VIRTUAL_MIN_ROWS = 120;
|
||||
|
||||
export interface VirtualWindowInput {
|
||||
/** Scroll offset of the scrolling container. */
|
||||
scrollTop: number;
|
||||
/** Visible (client) height of the scrolling container. */
|
||||
viewportHeight: number;
|
||||
/** Top of the table relative to the container's content (scrollTop space). */
|
||||
listTop: number;
|
||||
/** Measured uniform row height in px (clamped to >= 1). */
|
||||
rowHeight: number;
|
||||
/** Total row count. */
|
||||
total: number;
|
||||
/** Extra rows rendered above/below the viewport. Default 12. */
|
||||
overscan?: number;
|
||||
/** Start/end snap to this grid to avoid re-render churn. Default 6. */
|
||||
step?: number;
|
||||
}
|
||||
|
||||
export interface VirtualWindow {
|
||||
start: number;
|
||||
end: number;
|
||||
padTop: number;
|
||||
padBottom: number;
|
||||
}
|
||||
|
||||
export function computeWindow(i: VirtualWindowInput): VirtualWindow {
|
||||
if (i.total <= 0) return { start: 0, end: 0, padTop: 0, padBottom: 0 };
|
||||
const overscan = i.overscan ?? 12;
|
||||
const step = Math.max(1, i.step ?? 6);
|
||||
const rh = Math.max(1, i.rowHeight);
|
||||
|
||||
const firstVisible = Math.floor((i.scrollTop - i.listTop) / rh);
|
||||
const visibleCount = Math.ceil(i.viewportHeight / rh) + 1;
|
||||
|
||||
let start = Math.max(0, firstVisible - overscan);
|
||||
start = Math.floor(start / step) * step;
|
||||
let end = Math.min(i.total, Math.max(firstVisible, 0) + visibleCount + overscan);
|
||||
end = Math.min(i.total, Math.ceil(end / step) * step);
|
||||
|
||||
// Degenerate inputs (table far above/below viewport): keep a sane window.
|
||||
if (start >= end) {
|
||||
start = Math.min(start, Math.max(0, i.total - 1));
|
||||
end = Math.min(i.total, start + Math.max(visibleCount, 1));
|
||||
}
|
||||
|
||||
return {
|
||||
start,
|
||||
end,
|
||||
padTop: start * rh,
|
||||
padBottom: (i.total - end) * rh,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/** (#67) Warranty status computation for object asset tracking.
|
||||
*
|
||||
* Pure, side-effect-free, and `today`-injectable so it can be unit-tested
|
||||
* deterministically without faking the system clock.
|
||||
*/
|
||||
|
||||
export type WarrantyStatusKind = "valid" | "expiring" | "expired" | "none";
|
||||
|
||||
export interface WarrantyStatus {
|
||||
kind: WarrantyStatusKind;
|
||||
/** Whole days until expiry (negative once expired); null when no date. */
|
||||
days: number | null;
|
||||
/** The ISO date string echoed back, or null when absent/invalid. */
|
||||
date: string | null;
|
||||
}
|
||||
|
||||
/** Days before expiry at which the warranty is flagged as "expiring" (amber). */
|
||||
export const WARRANTY_WARN_DAYS = 60;
|
||||
|
||||
/**
|
||||
* Classify an object's warranty expiry date relative to `today`.
|
||||
*
|
||||
* @param iso ISO `YYYY-MM-DD` warranty expiry date (null/undefined/""/invalid → "none").
|
||||
* @param today Reference date; defaults to now. Injectable for deterministic tests.
|
||||
*/
|
||||
export function warrantyStatus(
|
||||
iso: string | null | undefined,
|
||||
today: Date = new Date(),
|
||||
): WarrantyStatus {
|
||||
if (!iso) return { kind: "none", days: null, date: null };
|
||||
const exp = new Date(`${iso}T00:00:00`);
|
||||
if (isNaN(exp.getTime())) return { kind: "none", days: null, date: null };
|
||||
// Normalize both ends to local midnight so the difference is whole days
|
||||
// regardless of the time-of-day component of `today`.
|
||||
const t0 = Date.UTC(today.getFullYear(), today.getMonth(), today.getDate());
|
||||
const t1 = Date.UTC(exp.getFullYear(), exp.getMonth(), exp.getDate());
|
||||
const days = Math.round((t1 - t0) / 86400000);
|
||||
if (days < 0) return { kind: "expired", days, date: iso };
|
||||
if (days <= WARRANTY_WARN_DAYS) return { kind: "expiring", days, date: iso };
|
||||
return { kind: "valid", days, date: iso };
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/** Task work sheet (v2.21) — a printable one-pager for a single task.
|
||||
*
|
||||
* Everything needed to actually DO the task, on one sheet of paper: object
|
||||
* + task details, the checklist as real tick boxes, the notes, and a QR
|
||||
* pair (open the task / complete it) so the paper links back to the panel.
|
||||
* When the task has a linked PDF manual with a page hint, the manual
|
||||
* excerpt pages are rendered INLINE (downscaled, two per row) via the
|
||||
* vendored pdf.js legacy build — the whole work sheet prints as one
|
||||
* document. The PDF link stays as the fallback when rendering fails.
|
||||
*
|
||||
* Mirrors helpers/report.ts: pure function building a self-contained HTML
|
||||
* document; the panel opens it in a new tab where the user prints or saves
|
||||
* as PDF. All user strings are escaped; labels arrive translated.
|
||||
*/
|
||||
|
||||
import type { MaintenanceTask } from "../types";
|
||||
|
||||
export interface WorksheetLabels {
|
||||
title: string; // "Work sheet"
|
||||
object: string;
|
||||
type: string;
|
||||
interval: string;
|
||||
nextDue: string;
|
||||
lastDone: string;
|
||||
priority: string;
|
||||
checklist: string;
|
||||
notes: string;
|
||||
scanView: string; // "Scan to open the task"
|
||||
scanComplete: string; // "Scan to complete"
|
||||
manualExcerpt: string; // "Manual excerpt"
|
||||
pages: string; // "pages"
|
||||
printedOn: string; // "Printed"
|
||||
never: string;
|
||||
typeLabel: (t: string) => string;
|
||||
statusLabel: (s: string) => string;
|
||||
}
|
||||
|
||||
export interface WorksheetExcerpt {
|
||||
title: string;
|
||||
startPage: number;
|
||||
endPage: number;
|
||||
url: string; // signed excerpt-endpoint URL (absolute)
|
||||
/** Absolute base URL of the vendored pdf.js assets; when set, the sheet
|
||||
* renders the excerpt pages inline (downscaled 2-up) via pdf.js. */
|
||||
vendorBase?: string;
|
||||
}
|
||||
|
||||
const esc = (v: unknown): string =>
|
||||
String(v ?? "").replace(/[&<>"']/g, (c) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] as string);
|
||||
|
||||
export function buildTaskWorksheetHtml(
|
||||
task: MaintenanceTask,
|
||||
objectName: string,
|
||||
L: WorksheetLabels,
|
||||
formatDate: (iso: string) => string,
|
||||
formatRecurrence: (task: MaintenanceTask) => string,
|
||||
qrViewDataUri: string | null,
|
||||
qrCompleteDataUri: string | null,
|
||||
excerpt: WorksheetExcerpt | null,
|
||||
nowIso: string,
|
||||
): string {
|
||||
const meta: Array<[string, string]> = [
|
||||
[L.object, esc(objectName)],
|
||||
[L.type, esc(L.typeLabel(task.type))],
|
||||
[L.interval, esc(formatRecurrence(task))],
|
||||
[L.nextDue, task.next_due ? esc(formatDate(task.next_due)) : "—"],
|
||||
[L.lastDone, task.last_performed ? esc(formatDate(task.last_performed)) : esc(L.never)],
|
||||
];
|
||||
if (task.priority && task.priority !== "normal") {
|
||||
meta.push([L.priority, esc(task.priority)]);
|
||||
}
|
||||
|
||||
const checklist = (task.checklist || [])
|
||||
.map((item) => `<li><span class="box"></span>${esc(item)}</li>`)
|
||||
.join("");
|
||||
|
||||
const qr = (uri: string | null, caption: string) =>
|
||||
uri
|
||||
? `<figure class="qr"><img src="${uri}" alt="" /><figcaption>${esc(caption)}</figcaption></figure>`
|
||||
: "";
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><title>${esc(task.name)} — ${esc(L.title)}</title>
|
||||
<style>
|
||||
@page { size: A4; margin: 14mm; }
|
||||
* { box-sizing: border-box; }
|
||||
body { font: 13px/1.45 -apple-system, "Segoe UI", Roboto, sans-serif; color: #111; margin: 0; }
|
||||
header { display: flex; justify-content: space-between; align-items: flex-start;
|
||||
border-bottom: 3px solid #111; padding-bottom: 8px; margin-bottom: 12px; }
|
||||
h1 { font-size: 22px; margin: 0 0 2px; }
|
||||
.obj { font-size: 14px; color: #444; }
|
||||
.qr-row { display: flex; gap: 18px; }
|
||||
.qr { margin: 0; text-align: center; }
|
||||
.qr img { width: 88px; height: 88px; display: block; }
|
||||
.qr figcaption { font-size: 9px; color: #555; max-width: 96px; }
|
||||
table.meta { border-collapse: collapse; margin-bottom: 12px; }
|
||||
table.meta td { padding: 2px 14px 2px 0; vertical-align: top; }
|
||||
table.meta td:first-child { color: #555; white-space: nowrap; }
|
||||
h2 { font-size: 13px; text-transform: uppercase; letter-spacing: 0.06em;
|
||||
border-bottom: 1px solid #bbb; padding-bottom: 2px; margin: 14px 0 6px; }
|
||||
ul.check { list-style: none; padding: 0; margin: 0; }
|
||||
ul.check li { display: flex; align-items: flex-start; gap: 8px; padding: 4px 0; font-size: 14px; }
|
||||
.box { width: 14px; height: 14px; border: 1.6px solid #111; border-radius: 2px;
|
||||
flex: 0 0 auto; margin-top: 2px; }
|
||||
.notes { white-space: pre-wrap; }
|
||||
.excerpt a { color: #0b57d0; word-break: break-all; }
|
||||
.excerpt-pages { display: flex; flex-wrap: wrap; gap: 3mm; margin-top: 4mm; }
|
||||
.excerpt-pages canvas { width: calc(50% - 2mm); height: auto;
|
||||
border: 0.4px solid #ccc; break-inside: avoid; }
|
||||
footer { position: fixed; bottom: 0; left: 0; right: 0; font-size: 9px; color: #888;
|
||||
border-top: 1px solid #ddd; padding-top: 3px; }
|
||||
@media screen { body { max-width: 800px; margin: 24px auto; padding: 0 16px; } }
|
||||
</style></head>
|
||||
<body>
|
||||
<header>
|
||||
<div>
|
||||
<h1>${esc(task.name)}</h1>
|
||||
<div class="obj">${esc(objectName)}</div>
|
||||
</div>
|
||||
<div class="qr-row">
|
||||
${qr(qrViewDataUri, L.scanView)}
|
||||
${qr(qrCompleteDataUri, L.scanComplete)}
|
||||
</div>
|
||||
</header>
|
||||
<table class="meta">
|
||||
${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>` : ""}
|
||||
${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}:
|
||||
<a href="${esc(excerpt.url)}" target="_blank" rel="noopener">PDF</a>
|
||||
</div>
|
||||
<div id="excerpt-pages" class="excerpt-pages"></div>
|
||||
${excerpt.vendorBase ? `<script type="module">
|
||||
// Render the excerpt pages inline (downscaled, two per row) so the
|
||||
// whole work sheet prints as ONE document. The link above stays as
|
||||
// the fallback if pdf.js or the fetch fails.
|
||||
try {
|
||||
const pdfjs = await import(${JSON.stringify(excerpt.vendorBase + "/pdf.min.mjs")});
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = ${JSON.stringify(excerpt.vendorBase + "/pdf.worker.min.mjs")};
|
||||
const doc = await pdfjs.getDocument({ url: ${JSON.stringify(excerpt.url)} }).promise;
|
||||
const host = document.getElementById("excerpt-pages");
|
||||
for (let n = 1; n <= doc.numPages; n++) {
|
||||
const page = await doc.getPage(n);
|
||||
const viewport = page.getViewport({ scale: 1.4 }); // crisp at ~50% print width
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = viewport.width; canvas.height = viewport.height;
|
||||
await page.render({ canvasContext: canvas.getContext("2d"), viewport }).promise;
|
||||
host.appendChild(canvas);
|
||||
}
|
||||
} catch (e) { console.warn("excerpt inline render failed", e); }
|
||||
</script>` : ""}` : ""}
|
||||
<footer>${esc(objectName)} · ${esc(task.name)} · ${esc(L.printedOn)} ${esc(nowIso.slice(0, 10))}</footer>
|
||||
</body></html>`;
|
||||
}
|
||||
@@ -0,0 +1,696 @@
|
||||
{
|
||||
"maintenance": "Údržba",
|
||||
"objects": "Objekty",
|
||||
"tasks": "Úkoly",
|
||||
"overdue": "Po termínu",
|
||||
"due_soon": "Brzy",
|
||||
"triggered": "Spuštěno",
|
||||
"ok": "OK",
|
||||
"all": "Vše",
|
||||
"new_object": "+ Nový objekt",
|
||||
"templates_from": "Ze šablony",
|
||||
"templates_title": "Začít ze šablony",
|
||||
"templates_task_count": "{n} úkolů",
|
||||
"template_created": "Vytvořeno ze šablony",
|
||||
"onboard_hint": "Přidejte první objekt a začněte sledovat údržbu.",
|
||||
"edit": "Upravit",
|
||||
"duplicate": "Duplikovat",
|
||||
"task_duplicated": "Úkol duplikován",
|
||||
"object_duplicated": "Objekt duplikován",
|
||||
"delete": "Smazat",
|
||||
"add_task": "+ Přidat úkol",
|
||||
"complete": "Dokončit",
|
||||
"completed": "Dokončeno",
|
||||
"skip": "Přeskočit",
|
||||
"skipped": "Přeskočeno",
|
||||
"missed": "Missed",
|
||||
"reset": "Reset",
|
||||
"snooze": "Snooze",
|
||||
"snoozed": "Snoozed",
|
||||
"cancel": "Zrušit",
|
||||
"bulk_select": "Vybrat",
|
||||
"bulk_select_all": "Vybrat vše",
|
||||
"bulk_n_selected": "{n} vybráno",
|
||||
"bulk_completed": "Dokončeno {n} úkolů",
|
||||
"bulk_archived": "Archivováno {n} úkolů",
|
||||
"completing": "Dokončování…",
|
||||
"interval": "Interval",
|
||||
"warning": "Upozornění",
|
||||
"last_performed": "Naposledy provedeno",
|
||||
"next_due": "Další termín",
|
||||
"days_until_due": "Dnů do termínu",
|
||||
"avg_duration": "Prům. trvání",
|
||||
"trigger": "Spouštěč",
|
||||
"trigger_type": "Typ spouštěče",
|
||||
"threshold_above": "Horní limit",
|
||||
"threshold_below": "Dolní limit",
|
||||
"threshold": "Práh",
|
||||
"counter": "Čítač",
|
||||
"state_change": "Změna stavu",
|
||||
"runtime": "Doba běhu",
|
||||
"runtime_hours": "Cílová doba běhu (hodiny)",
|
||||
"target_value": "Cílová hodnota",
|
||||
"baseline": "Základní hodnota",
|
||||
"target_changes": "Cílový počet změn",
|
||||
"for_minutes": "Po dobu (minut)",
|
||||
"time_based": "Časový",
|
||||
"sensor_based": "Založený na senzoru",
|
||||
"manual": "Manuální",
|
||||
"one_time": "Jednorázově",
|
||||
"weekdays": "Dny v týdnu",
|
||||
"nth_weekday": "N-tý den v týdnu v měsíci",
|
||||
"day_of_month": "Den v měsíci",
|
||||
"recurrence_on_days": "Opakovat v",
|
||||
"recurrence_occurrence": "Výskyt",
|
||||
"recurrence_weekday": "Den v týdnu",
|
||||
"recurrence_day": "Den v měsíci (1–31)",
|
||||
"recurrence_last_day": "Last day of the month",
|
||||
"recurrence_business_day": "Business days only (roll back from weekend)",
|
||||
"recurrence_offset": "Offset (days, ±)",
|
||||
"recurrence_offset_help": "Shift the date by ±N days, e.g. -2 = two days before.",
|
||||
"last_day_month": "Last day of month",
|
||||
"last_business_day_month": "Last business day",
|
||||
"ord_1": "1.",
|
||||
"ord_2": "2.",
|
||||
"ord_3": "3.",
|
||||
"ord_4": "4.",
|
||||
"ord_5": "5.",
|
||||
"ord_last": "Poslední",
|
||||
"day_word": "Den",
|
||||
"interval_value": "Interval",
|
||||
"interval_unit": "Jednotka",
|
||||
"unit_days": "Dny",
|
||||
"unit_weeks": "Týdny",
|
||||
"unit_months": "Měsíce",
|
||||
"unit_years": "Roky",
|
||||
"due_date": "Datum splnění",
|
||||
"cleaning": "Čištění",
|
||||
"inspection": "Inspekce",
|
||||
"replacement": "Výměna",
|
||||
"calibration": "Kalibrace",
|
||||
"service": "Servis",
|
||||
"reading": "Odečet",
|
||||
"custom": "Vlastní",
|
||||
"history": "Historie",
|
||||
"cost": "Náklady",
|
||||
"report_button": "Zpráva",
|
||||
"report_title": "Zpráva o údržbě",
|
||||
"report_generated": "Vytvořeno",
|
||||
"report_times_done": "Provedeno",
|
||||
"report_total_cost": "Celkové náklady",
|
||||
"report_every": "každých {n} {unit}",
|
||||
"report_notes": "Poznámky",
|
||||
"report_col_type": "Typ",
|
||||
"report_col_status": "Stav",
|
||||
"report_col_schedule": "Plán",
|
||||
"duration": "Doba trvání",
|
||||
"both": "Obojí",
|
||||
"trigger_val": "Hodnota spouštěče",
|
||||
"complete_title": "Dokončit: ",
|
||||
"checklist": "Kontrolní seznam",
|
||||
"checklist_steps_optional": "Kroky kontrolního seznamu (volitelné)",
|
||||
"checklist_placeholder": "Vyčistit filtr\nVyměnit těsnění\nOtestovat tlak",
|
||||
"checklist_help": "Jeden krok na řádek. Max 100 položek.",
|
||||
"err_too_long": "{field}: příliš dlouhé (max {n} znaků)",
|
||||
"err_too_short": "{field}: příliš krátké (min {n} znaků)",
|
||||
"err_value_too_high": "{field}: příliš velké (max {n})",
|
||||
"err_value_too_low": "{field}: příliš malé (min {n})",
|
||||
"err_required": "{field}: povinné",
|
||||
"err_wrong_type": "{field}: špatný typ (očekáván: {type})",
|
||||
"err_invalid_choice": "{field}: nepovolená hodnota",
|
||||
"err_invalid_value": "{field}: neplatná hodnota",
|
||||
"feat_schedule_time": "Plánování podle denní doby",
|
||||
"feat_schedule_time_desc": "Úkoly se stanou po termínu v určenou denní dobu místo o půlnoci.",
|
||||
"schedule_time_optional": "Termín v čase (volitelné, HH:MM)",
|
||||
"schedule_time_help": "Prázdné = půlnoc (výchozí). Časové pásmo HA.",
|
||||
"at_time": "v",
|
||||
"notes_optional": "Poznámky (volitelné)",
|
||||
"cost_optional": "Náklady (volitelné)",
|
||||
"duration_minutes": "Doba trvání v minutách (volitelné)",
|
||||
"days": "dní",
|
||||
"day": "den",
|
||||
"today": "Dnes",
|
||||
"d_overdue": "d po termínu",
|
||||
"no_tasks": "Zatím žádné úkoly údržby. Vytvořte objekt pro začátek.",
|
||||
"no_tasks_short": "Žádné úkoly",
|
||||
"no_history": "Zatím žádné záznamy historie.",
|
||||
"show_all": "Zobrazit vše",
|
||||
"cost_duration_chart": "Náklady a doba trvání",
|
||||
"installed": "Nainstalováno",
|
||||
"confirm_delete_object": "Smazat tento objekt a všechny jeho úkoly?",
|
||||
"confirm_delete_task": "Smazat tento úkol?",
|
||||
"min": "Min",
|
||||
"max": "Max",
|
||||
"save": "Uložit",
|
||||
"saving": "Ukládání…",
|
||||
"edit_task": "Upravit úkol",
|
||||
"new_task": "Nový úkol údržby",
|
||||
"task_name": "Název úkolu",
|
||||
"maintenance_type": "Typ údržby",
|
||||
"priority": "Priorita",
|
||||
"labels": "Labels",
|
||||
"labels_placeholder": "e.g. safety, seasonal, tenant-visible",
|
||||
"labels_help": "Comma-separated tags for filtering and reporting.",
|
||||
"priority_low": "Nízká",
|
||||
"priority_normal": "Normální",
|
||||
"priority_high": "Vysoká",
|
||||
"schedule_type": "Typ rozvrhu",
|
||||
"interval_days": "Interval (dny)",
|
||||
"warning_days": "Dny upozornění",
|
||||
"earliest_completion_days": "Earliest completion (days before due)",
|
||||
"earliest_completion_days_help": "Leave empty to allow completing any time. 0 = only on/after the due date.",
|
||||
"last_performed_optional": "Naposledy provedeno (volitelné)",
|
||||
"interval_anchor": "Ukotvení intervalu",
|
||||
"anchor_completion": "Od data dokončení",
|
||||
"anchor_planned": "Od plánovaného data (bez posunu)",
|
||||
"edit_object": "Upravit objekt",
|
||||
"name": "Název",
|
||||
"manufacturer_optional": "Výrobce (volitelné)",
|
||||
"model_optional": "Model (volitelné)",
|
||||
"serial_number_optional": "Sériové číslo (volitelné)",
|
||||
"serial_number_label": "S/N",
|
||||
"documentation_url_label": "Návod",
|
||||
"object_notes_label": "Poznámky",
|
||||
"sort_due_date": "Termín",
|
||||
"sort_object": "Název objektu",
|
||||
"sort_type": "Typ",
|
||||
"sort_task_name": "Název úkolu",
|
||||
"all_objects": "Všechny objekty",
|
||||
"tasks_lower": "úkolů",
|
||||
"no_tasks_yet": "Zatím žádné úkoly",
|
||||
"add_first_task": "Přidat první úkol",
|
||||
"trigger_configuration": "Konfigurace spouštěče",
|
||||
"entity_id": "ID entity",
|
||||
"comma_separated": "oddělené čárkami",
|
||||
"entity_logic": "Logika entit",
|
||||
"entity_logic_any": "Spustí libovolná entita",
|
||||
"entity_logic_all": "Všechny entity musí spustit",
|
||||
"entities": "entity",
|
||||
"attribute_optional": "Atribut (volitelný, prázdný = stav)",
|
||||
"use_entity_state": "Použít stav entity (bez atributu)",
|
||||
"trigger_above": "Spustit nad",
|
||||
"trigger_below": "Spustit pod",
|
||||
"for_at_least_minutes": "Po dobu alespoň (minut)",
|
||||
"safety_interval_days": "Bezpečnostní interval (dny, volitelný)",
|
||||
"safety_interval": "Bezpečnostní interval (volitelný)",
|
||||
"delta_mode": "Režim delta",
|
||||
"from_state_optional": "Ze stavu (volitelné)",
|
||||
"to_state_optional": "Do stavu (volitelné)",
|
||||
"documentation_url_optional": "URL dokumentace (volitelné)",
|
||||
"object_notes_optional": "Poznámky (volitelné)",
|
||||
"nfc_tag_id_optional": "ID NFC tagu (volitelné)",
|
||||
"nfc_tags_empty_help": "V Home Assistant zatím nejsou registrovány žádné NFC tagy.",
|
||||
"nfc_tags_open_settings": "Otevřít nastavení tagů",
|
||||
"nfc_tags_refresh": "Obnovit",
|
||||
"environmental_entity_optional": "Senzor prostředí (volitelný)",
|
||||
"environmental_entity_helper": "např. sensor.outdoor_temperature — upravuje interval podle podmínek prostředí",
|
||||
"environmental_attribute_optional": "Atribut prostředí (volitelný)",
|
||||
"nfc_tag_id": "ID NFC tagu",
|
||||
"nfc_linked": "NFC tag propojen",
|
||||
"nfc_link_hint": "Klikněte pro propojení NFC tagu",
|
||||
"responsible_user": "Zodpovědný uživatel",
|
||||
"shared_with": "Shared with (rotation)",
|
||||
"shared_with_help": "Pick multiple people to share this task; the responsible person rotates on each completion.",
|
||||
"rotation_strategy": "Rotation",
|
||||
"rotation_none": "No rotation",
|
||||
"rotation_round_robin": "Round-robin",
|
||||
"rotation_least_completed": "Least completed",
|
||||
"rotation_random": "Random",
|
||||
"no_user_assigned": "(Žádný uživatel přiřazen)",
|
||||
"all_users": "Všichni uživatelé",
|
||||
"my_tasks": "Moje úkoly",
|
||||
"tab_calendar": "Kalendář",
|
||||
"cal_no_events": "Bez údržby",
|
||||
"cal_window_7": "7 dní",
|
||||
"cal_window_14": "14 dní",
|
||||
"cal_window_30": "30 dní",
|
||||
"cal_window_365": "1 rok",
|
||||
"cal_every_n_days": "každých {n} dní",
|
||||
"cal_source_time": "Čas",
|
||||
"cal_source_time_adaptive": "Čas (adaptivní)",
|
||||
"cal_source_sensor": "Senzor",
|
||||
"cal_predicted": "predikce",
|
||||
"cal_confidence_high": "vysoká přesnost",
|
||||
"cal_confidence_medium": "střední přesnost",
|
||||
"cal_confidence_low": "nízká přesnost",
|
||||
"budget_monthly": "Měsíční rozpočet",
|
||||
"budget_yearly": "Roční rozpočet",
|
||||
"groups": "Skupiny",
|
||||
"new_group": "Nová skupina",
|
||||
"edit_group": "Upravit skupinu",
|
||||
"no_groups": "Zatím žádné skupiny",
|
||||
"delete_group": "Smazat skupinu",
|
||||
"delete_group_confirm": "Smazat skupinu '{name}'?",
|
||||
"group_select_tasks": "Vybrat úkoly",
|
||||
"group_name_required": "Název je povinný",
|
||||
"description_optional": "Popis (volitelný)",
|
||||
"selected": "Vybráno",
|
||||
"loading_chart": "Načítání dat grafu...",
|
||||
"hide_outliers": "Skrýt odlehlé hodnoty (chyby čidla)",
|
||||
"was_maintenance_needed": "Byla tato údržba potřeba?",
|
||||
"feedback_needed": "Potřebná",
|
||||
"feedback_not_needed": "Nepotřebná",
|
||||
"feedback_not_sure": "Nejsem si jistý",
|
||||
"suggested_interval": "Navrhovaný interval",
|
||||
"apply_suggestion": "Použít",
|
||||
"reanalyze": "Znovu analyzovat",
|
||||
"reanalyze_result": "Nová analýza",
|
||||
"reanalyze_insufficient_data": "Nedostatek dat pro vytvoření doporučení",
|
||||
"data_points": "datových bodů",
|
||||
"dismiss_suggestion": "Zavřít",
|
||||
"confidence_low": "Nízká",
|
||||
"confidence_medium": "Střední",
|
||||
"confidence_high": "Vysoká",
|
||||
"recommended": "doporučeno",
|
||||
"seasonal_awareness": "Sezónní povědomí",
|
||||
"edit_seasonal_overrides": "Upravit sezónní faktory",
|
||||
"seasonal_overrides_title": "Sezónní faktory (přepsání)",
|
||||
"seasonal_overrides_hint": "Faktor na měsíc (0.1–5.0). Prázdné = naučeno automaticky.",
|
||||
"seasonal_override_invalid": "Neplatná hodnota",
|
||||
"seasonal_override_range": "Faktor musí být mezi 0.1 a 5.0",
|
||||
"clear_all": "Vymazat vše",
|
||||
"seasonal_chart_title": "Sezónní faktory",
|
||||
"seasonal_learned": "Naučené",
|
||||
"seasonal_manual": "Manuální",
|
||||
"month_jan": "Led",
|
||||
"month_feb": "Úno",
|
||||
"month_mar": "Bře",
|
||||
"month_apr": "Dub",
|
||||
"month_may": "Kvě",
|
||||
"month_jun": "Čer",
|
||||
"month_jul": "Čvc",
|
||||
"month_aug": "Srp",
|
||||
"month_sep": "Zář",
|
||||
"month_oct": "Říj",
|
||||
"month_nov": "Lis",
|
||||
"month_dec": "Pro",
|
||||
"sensor_prediction": "Predikce senzoru",
|
||||
"degradation_trend": "Trend",
|
||||
"trend_rising": "Rostoucí",
|
||||
"trend_falling": "Klesající",
|
||||
"trend_stable": "Stabilní",
|
||||
"trend_insufficient_data": "Nedostatek dat",
|
||||
"days_until_threshold": "Dnů do prahu",
|
||||
"threshold_exceeded": "Práh překročen",
|
||||
"environmental_adjustment": "Faktor prostředí",
|
||||
"sensor_prediction_urgency": "Senzor předpovídá práh za ~{days} dní",
|
||||
"day_short": "d",
|
||||
"weibull_reliability_curve": "Křivka spolehlivosti",
|
||||
"weibull_failure_probability": "Pravděpodobnost selhání",
|
||||
"weibull_r_squared": "Shoda R²",
|
||||
"beta_early_failures": "Časná selhání",
|
||||
"beta_random_failures": "Náhodná selhání",
|
||||
"beta_wear_out": "Opotřebení",
|
||||
"beta_highly_predictable": "Vysoce předvídatelné",
|
||||
"confidence_interval": "Interval spolehlivosti",
|
||||
"confidence_conservative": "Konzervativní",
|
||||
"confidence_aggressive": "Optimistický",
|
||||
"current_interval_marker": "Aktuální interval",
|
||||
"recommended_marker": "Doporučený",
|
||||
"characteristic_life": "Charakteristická životnost",
|
||||
"chart_mini_sparkline": "Graf trendu",
|
||||
"chart_history": "Historie nákladů a doby trvání",
|
||||
"chart_seasonal": "Sezónní faktory, 12 měsíců",
|
||||
"chart_weibull": "Weibullova křivka spolehlivosti",
|
||||
"chart_sparkline": "Graf hodnoty spouštěče senzoru",
|
||||
"days_progress": "Postup dnů",
|
||||
"qr_code": "QR kód",
|
||||
"qr_generating": "Generování QR kódu…",
|
||||
"qr_error": "Nepodařilo se vygenerovat QR kód.",
|
||||
"qr_error_no_url": "Není nakonfigurováno URL HA. Nastavte externí nebo interní URL v Nastavení → Systém → Síť.",
|
||||
"save_error": "Nepodařilo se uložit. Zkuste to znovu.",
|
||||
"qr_print": "Tisk",
|
||||
"qr_download": "Stáhnout SVG",
|
||||
"qr_action": "Akce při skenování",
|
||||
"qr_action_view": "Zobrazit informace o údržbě",
|
||||
"qr_action_complete": "Označit údržbu jako dokončenou",
|
||||
"qr_url_mode": "Typ odkazu",
|
||||
"qr_mode_companion": "Companion App",
|
||||
"qr_mode_local": "Lokální (mDNS)",
|
||||
"qr_mode_server": "URL serveru",
|
||||
"overview": "Přehled",
|
||||
"analysis": "Analýza",
|
||||
"recent_activities": "Nedávné aktivity",
|
||||
"search_notes": "Hledat v poznámkách",
|
||||
"avg_cost": "Prům. náklady",
|
||||
"no_advanced_features": "Žádné pokročilé funkce nejsou povoleny",
|
||||
"no_advanced_features_hint": "Povolte „Adaptivní intervaly” nebo „Sezónní vzory” v nastavení integrace pro zobrazení analytických dat.",
|
||||
"analysis_not_enough_data": "Zatím nedostatek dat pro analýzu.",
|
||||
"analysis_not_enough_data_hint": "Weibullova analýza vyžaduje alespoň 5 dokončených údržeb; sezónní vzory se stanou viditelné po 6+ datových bodech na měsíc.",
|
||||
"analysis_manual_task_hint": "Manuální úkoly bez intervalu negenerují analytická data.",
|
||||
"completions": "dokončení",
|
||||
"current": "Aktuální",
|
||||
"shorter": "Kratší",
|
||||
"longer": "Delší",
|
||||
"normal": "Normální",
|
||||
"disabled": "Zakázáno",
|
||||
"compound_logic": "Složená logika",
|
||||
"compound": "Složené (více podmínek)",
|
||||
"compound_logic_and": "A — musí platit všechny podmínky",
|
||||
"compound_logic_or": "NEBO — stačí libovolná podmínka",
|
||||
"compound_help": "Zkombinujte několik podmínek senzorů do jednoho spouštěče.",
|
||||
"compound_no_conditions": "Zatím žádné podmínky — přidejte alespoň jednu.",
|
||||
"compound_add_condition": "Přidat podmínku",
|
||||
"compound_condition": "Podmínka",
|
||||
"compound_remove_condition": "Odebrat podmínku",
|
||||
"card_title": "Název",
|
||||
"card_show_header": "Zobrazit záhlaví se statistikami",
|
||||
"card_show_actions": "Zobrazit tlačítka akcí",
|
||||
"card_compact": "Kompaktní režim",
|
||||
"card_max_items": "Max položek (0 = vše)",
|
||||
"card_filter_status": "Filtrovat podle stavu",
|
||||
"card_filter_status_help": "Prázdné = zobrazit všechny stavy.",
|
||||
"card_filter_objects": "Filtrovat podle objektů",
|
||||
"card_filter_objects_help": "Prázdné = zobrazit všechny objekty.",
|
||||
"card_filter_entities": "Filtrovat podle entit (entity_ids)",
|
||||
"card_filter_entities_help": "Vyberte entity sensor / binary_sensor z této integrace. Prázdné = všechny.",
|
||||
"card_loading_objects": "Načítání objektů…",
|
||||
"card_load_error": "Nepodařilo se načíst objekty — zkontrolujte WebSocket spojení.",
|
||||
"card_no_tasks_title": "Zatím žádné úkoly údržby",
|
||||
"card_no_tasks_cta": "→ Vytvořte v panelu Maintenance",
|
||||
"no_objects": "Zatím žádné objekty.",
|
||||
"action_error": "Akce se nezdařila. Zkuste to znovu.",
|
||||
"area_id_optional": "Oblast (volitelná)",
|
||||
"installation_date_optional": "Datum instalace (volitelné)",
|
||||
"warranty_expiry_optional": "Konec záruky (volitelné)",
|
||||
"warranty": "Záruka",
|
||||
"warranty_valid_until": "platná do {date}",
|
||||
"warranty_expires_in": "vyprší za {days} dní",
|
||||
"warranty_expired": "vypršela",
|
||||
"cal_past_windows": "Minulá okna",
|
||||
"cal_forward_windows": "Budoucí okna",
|
||||
"history_edit_title": "Upravit záznam historie",
|
||||
"history_edit_timestamp": "Časové razítko",
|
||||
"manufacturer": "Výrobce",
|
||||
"model": "Model",
|
||||
"area": "Oblast",
|
||||
"actions": "Akce",
|
||||
"view_mode_label": "Zobrazení",
|
||||
"view_cards": "Zobrazení karet",
|
||||
"view_table": "Zobrazení tabulky",
|
||||
"objects_table_columns_label": "Sloupce tabulky objektů",
|
||||
"objects_table_columns_hint": "Vyberte, které sloupce se zobrazí v tabulkovém zobrazení objektů.",
|
||||
"custom_icon_optional": "Ikona (volitelná, např. mdi:wrench)",
|
||||
"task_enabled": "Úkol povolen",
|
||||
"skip_reason_prompt": "Přeskočit tento úkol?",
|
||||
"reason_optional": "Důvod (volitelný)",
|
||||
"reset_date_prompt": "Označit úkol jako provedený?",
|
||||
"reset_date_optional": "Datum posledního provedení (volitelné, výchozí dnes)",
|
||||
"notes_label": "Poznámky",
|
||||
"documentation_label": "Dokumentace",
|
||||
"no_nfc_tag": "— Žádný tag —",
|
||||
"dashboard": "Přehled",
|
||||
"tab_today": "Dnes",
|
||||
"palette_placeholder": "Hledat objekty a úkoly…",
|
||||
"palette_no_results": "Žádné výsledky",
|
||||
"palette_hint": "↑↓ pohyb · Enter otevřít · Esc zavřít",
|
||||
"today_all_caught_up": "Vše hotovo! Tento týden nic nezbývá.",
|
||||
"today_overdue": "Po termínu",
|
||||
"today_due_today": "Dnes",
|
||||
"today_this_week": "Tento týden",
|
||||
"settings": "Nastavení",
|
||||
"settings_features": "Pokročilé funkce",
|
||||
"settings_features_desc": "Povolte nebo zakažte pokročilé funkce. Zakázání je skryje z UI, ale nesmaže data.",
|
||||
"feat_adaptive": "Adaptivní plánování",
|
||||
"feat_adaptive_desc": "Učte se optimální intervaly z historie údržby",
|
||||
"feat_predictions": "Predikce senzorů",
|
||||
"feat_predictions_desc": "Předpovídejte termíny spouštění z degradace senzoru",
|
||||
"feat_seasonal": "Sezónní úpravy",
|
||||
"feat_seasonal_desc": "Upravte intervaly podle sezónních vzorů",
|
||||
"feat_environmental": "Korelace s prostředím",
|
||||
"feat_environmental_desc": "Korelujte intervaly s teplotou/vlhkostí",
|
||||
"feat_budget": "Sledování rozpočtu",
|
||||
"feat_budget_desc": "Sledujte měsíční a roční výdaje na údržbu",
|
||||
"feat_groups": "Skupiny úkolů",
|
||||
"feat_groups_desc": "Organizujte úkoly do logických skupin",
|
||||
"feat_checklists": "Kontrolní seznamy",
|
||||
"feat_checklists_desc": "Vícestupňové procedury pro dokončení úkolu",
|
||||
"settings_general": "Obecné",
|
||||
"settings_default_warning": "Výchozí dny upozornění",
|
||||
"settings_panel_enabled": "Boční panel",
|
||||
"settings_panel_title": "Název panelu",
|
||||
"settings_notifications": "Oznámení",
|
||||
"settings_notify_service": "Služba oznámení",
|
||||
"test_notification": "Testovací oznámení",
|
||||
"send_test": "Odeslat test",
|
||||
"testing": "Odesílání…",
|
||||
"test_notification_success": "Testovací oznámení odesláno",
|
||||
"test_notification_failed": "Testovací oznámení se nezdařilo",
|
||||
"settings_notify_due_soon": "Oznámit když brzy",
|
||||
"settings_notify_overdue": "Oznámit když po termínu",
|
||||
"settings_notify_triggered": "Oznámit když spuštěno",
|
||||
"settings_interval_hours": "Interval opakování (hodiny, 0 = jednou)",
|
||||
"settings_quiet_hours": "Tiché hodiny",
|
||||
"settings_quiet_start": "Začátek",
|
||||
"settings_quiet_end": "Konec",
|
||||
"settings_max_per_day": "Max oznámení denně (0 = bez limitu)",
|
||||
"settings_bundling": "Seskupit oznámení",
|
||||
"settings_bundle_threshold": "Práh seskupení",
|
||||
"settings_reminder_leads": "Extra reminders (days before due)",
|
||||
"settings_reminder_leads_hint": "Comma-separated lead times, e.g. 14, 3, 0 — one extra reminder fires on each matching day. Empty = off.",
|
||||
"settings_actions": "Mobilní akční tlačítka",
|
||||
"settings_action_complete": "Zobrazit tlačítko 'Dokončit'",
|
||||
"settings_action_skip": "Zobrazit tlačítko 'Přeskočit'",
|
||||
"settings_action_snooze": "Zobrazit tlačítko 'Odložit'",
|
||||
"settings_weekly_digest": "Týdenní přehled",
|
||||
"settings_weekly_digest_hint": "Jedno souhrnné oznámení v pondělí ráno, když jsou úkoly.",
|
||||
"settings_warranty_reminder": "Warranty expiry reminder",
|
||||
"settings_warranty_reminder_days": "Days before expiry",
|
||||
"settings_warranty_reminder_hint": "Notify once when an object's warranty is this many days from expiring.",
|
||||
"settings_snooze_hours": "Doba odložení (hodiny)",
|
||||
"settings_budget": "Rozpočet",
|
||||
"settings_currency": "Měna",
|
||||
"settings_budget_monthly": "Měsíční rozpočet",
|
||||
"settings_budget_yearly": "Roční rozpočet",
|
||||
"settings_budget_alerts": "Rozpočtová upozornění",
|
||||
"settings_budget_threshold": "Práh upozornění (%)",
|
||||
"settings_import_export": "Import / Export",
|
||||
"settings_export_json": "Exportovat JSON",
|
||||
"settings_export_yaml": "Exportovat YAML",
|
||||
"settings_export_csv": "Exportovat CSV",
|
||||
"settings_import_csv": "Importovat CSV",
|
||||
"settings_import_placeholder": "Vložte sem obsah JSON nebo CSV…",
|
||||
"settings_import_btn": "Importovat",
|
||||
"settings_import_success": "{count} objektů úspěšně importováno.",
|
||||
"settings_export_success": "Export stažen.",
|
||||
"settings_saved": "Nastavení uloženo.",
|
||||
"settings_include_history": "Zahrnout historii",
|
||||
"sort_alphabetical": "Abecedně",
|
||||
"sort_due_soonest": "Nejbližší termín",
|
||||
"sort_task_count": "Počet úkolů",
|
||||
"sort_area": "Oblast",
|
||||
"sort_assigned_user": "Přiřazený uživatel",
|
||||
"sort_group": "Skupina",
|
||||
"groupby_none": "Bez seskupení",
|
||||
"groupby_area": "Podle oblasti",
|
||||
"groupby_group": "Podle skupiny",
|
||||
"groupby_user": "Podle uživatele",
|
||||
"filter_label": "Filtr",
|
||||
"user_label": "Uživatel",
|
||||
"sort_label": "Řazení",
|
||||
"group_by_label": "Seskupit podle",
|
||||
"state_value_help": "Použijte hodnotu stavu HA (obvykle malými písmeny, např. \"on\"/\"off\"). Velikost písmen se při uložení normalizuje.",
|
||||
"target_changes_help": "Počet odpovídajících přechodů, po kterých se trigger spustí (výchozí: 1).",
|
||||
"qr_print_title": "Tisk QR kódů",
|
||||
"qr_print_desc": "Vygeneruj tiskovou stránku s QR kódy k vystřižení a nalepení na zařízení.",
|
||||
"qr_print_load": "Načíst objekty",
|
||||
"qr_print_filter": "Filtr",
|
||||
"qr_print_objects": "Objekty",
|
||||
"qr_print_actions": "Akce",
|
||||
"qr_print_url_mode": "Typ odkazu",
|
||||
"qr_print_estimate": "Odhad QR kódů",
|
||||
"qr_print_over_limit": "limit 200, zužte filtr",
|
||||
"qr_print_generate": "Vygenerovat QR kódy",
|
||||
"qr_print_generating": "Generování…",
|
||||
"qr_print_ready": "QR kódy připraveny",
|
||||
"qr_print_print_button": "Tisk",
|
||||
"qr_print_empty": "Nic k vygenerování",
|
||||
"qr_action_skip": "Přeskočit",
|
||||
"vacation_title": "Režim dovolené",
|
||||
"vacation_active": "aktivní",
|
||||
"vacation_ended": "ukončeno",
|
||||
"vacation_desc": "Naplánuj dovolenou: oznámení jsou pozastavena během období plus dny rezervy. Lze definovat výjimky pro jednotlivé úkoly.",
|
||||
"vacation_enable": "Zapnout režim dovolené",
|
||||
"vacation_start": "Začátek",
|
||||
"vacation_end": "Konec",
|
||||
"vacation_buffer": "Rezerva (dnů)",
|
||||
"vacation_exempt_title": "Upozorňovat i přes dovolenou",
|
||||
"vacation_exempt_desc": "Vyber úkoly, které mají upozorňovat i během dovolené (např. kritická chemie bazénu).",
|
||||
"vacation_load_tasks": "Načíst úkoly",
|
||||
"vacation_preview_btn": "Zobrazit náhled",
|
||||
"vacation_preview_affected": "úkolů ovlivněno",
|
||||
"vacation_event_due_soon": "blíží se termín",
|
||||
"vacation_event_overdue": "stane se po termínu",
|
||||
"vacation_event_triggered_est": "možné spuštění senzoru",
|
||||
"vacation_sensor_based": "(senzorové)",
|
||||
"vacation_action_notify": "Přesto upozornit",
|
||||
"vacation_action_unsilence": "Znovu ztlumit",
|
||||
"vacation_marked_complete": "Označeno jako dokončené",
|
||||
"vacation_marked_skip": "Přeskočeno",
|
||||
"vacation_end_now": "Ukončit dovolenou nyní",
|
||||
"unassigned": "Nepřiřazeno",
|
||||
"no_area": "Bez oblasti",
|
||||
"has_overdue": "Má úkoly po termínu",
|
||||
"object": "Objekt",
|
||||
"settings_panel_access": "Přístup k panelu",
|
||||
"settings_panel_access_desc": "Administrátoři mají vždy plný přístup. Chcete-li delegovat vytváření, úpravy a mazání konkrétním uživatelům bez práv administrátora, zapněte tuto možnost a vyberte je níže — ostatní vidí pouze Dokončit a Přeskočit.",
|
||||
"settings_operator_write": "Povolit vybraným uživatelům vytvářet, upravovat a mazat",
|
||||
"settings_operator_write_desc": "Vypnuto: obsah mohou měnit pouze administrátoři. Zapnuto: plný přístup získají i vybraní uživatelé níže.",
|
||||
"no_non_admin_users": "Nenalezeni žádní uživatelé bez admin práv. Přidejte je v Nastavení → Lidé.",
|
||||
"owner_label": "Vlastník",
|
||||
"feat_completion_actions": "Akce při dokončení",
|
||||
"feat_completion_actions_desc": "Akce HA na úlohu při dokončení + QR rychlého dokončení s předvolenými hodnotami.",
|
||||
"on_complete_action_title": "Při dokončení: spustit akci HA (volitelné)",
|
||||
"on_complete_action_desc": "Volá službu HA, když je úloha dokončena — např. resetuje čítač na zařízení.",
|
||||
"on_complete_action_service": "Služba",
|
||||
"on_complete_action_target": "Cílová entita",
|
||||
"on_complete_action_data": "Data (JSON, volitelné)",
|
||||
"on_complete_action_test": "Testovat akci",
|
||||
"on_complete_action_test_success": "Úspěch",
|
||||
"on_complete_action_test_failed": "Selhalo",
|
||||
"quick_complete_defaults_title": "Výchozí hodnoty rychlého dokončení (pro QR skenů, volitelné)",
|
||||
"quick_complete_defaults_desc": "Předvolené hodnoty pro QR rychlého dokončení. Bez nich QR otevře dialog.",
|
||||
"quick_complete_defaults_notes": "Poznámky",
|
||||
"quick_complete_defaults_cost": "Cena",
|
||||
"quick_complete_defaults_duration": "Trvání (minuty)",
|
||||
"quick_complete_defaults_feedback_none": "Bez zpětné vazby",
|
||||
"quick_complete_defaults_feedback_needed": "Bylo potřeba",
|
||||
"quick_complete_defaults_feedback_not_needed": "Nebylo potřeba",
|
||||
"quick_complete_success": "Rychle označeno jako hotové",
|
||||
"trigger_replaced": "Spouštěč nahrazen",
|
||||
"add": "Přidat",
|
||||
"show_stats": "Zobrazit statistiky + grafy",
|
||||
"hide_stats": "Skrýt statistiky",
|
||||
"adaptive_no_data": "Zatím není dostatek historie dokončení pro adaptivní analýzu. Dokončete tento úkol ještě několikrát, abyste odemkli doporučení intervalu a grafy spolehlivosti.",
|
||||
"suggestion_applied": "Navržený interval použit",
|
||||
"vacation_mode": "Režim dovolené",
|
||||
"vacation_status_active": "Nyní aktivní",
|
||||
"vacation_status_scheduled": "Naplánováno",
|
||||
"vacation_status_inactive": "Neaktivní",
|
||||
"vacation_end_now_confirm": "Ukončit dovolenou okamžitě?",
|
||||
"vacation_exempt_count": "vyňato",
|
||||
"vacation_advanced": "Pokročilé…",
|
||||
"vacation_open_panel": "Otevřít v panelu",
|
||||
"enable": "Zapnout",
|
||||
"saved": "Uloženo",
|
||||
"budget_monthly_set": "Nastavit měsíční",
|
||||
"budget_yearly_set": "Nastavit roční",
|
||||
"budget_advanced": "Měna, výstrahy…",
|
||||
"budget_open_panel": "Otevřít v panelu",
|
||||
"groups_empty": "Zatím žádné skupiny.",
|
||||
"group_new_placeholder": "Přidat skupinu…",
|
||||
"group_delete_confirm": "Smazat skupinu „{name}\"?",
|
||||
"groups_manage_tasks": "Spravovat přiřazení úkolů…",
|
||||
"groups_open_panel": "Otevřít v panelu",
|
||||
"on_complete_action_target_hint": "Poznámka: doména entity musí odpovídat službě — např. 'button.press' funguje jen na button.*, 'counter.increment' jen na counter.*, 'input_button.press' jen na input_button.* atd. Při neshodě akce tiše selže (HA zaznamená 'Referenced entities ... missing or not currently available').",
|
||||
"show_all_objects": "Zobrazit všechny objekty",
|
||||
"show_all_tasks": "Vymazat filtr — zobrazit všechny úkoly",
|
||||
"filter_to_overdue": "Filtrovat seznam jen na po termínu",
|
||||
"filter_to_due_soon": "Filtrovat seznam jen na blížící se termín",
|
||||
"filter_to_triggered": "Filtrovat seznam jen na spuštěné",
|
||||
"open_task": "Otevřít úkol",
|
||||
"show_details": "Zobrazit historii + statistiky",
|
||||
"hide_details": "Skrýt podrobnosti",
|
||||
"history_empty": "Zatím žádná historie.",
|
||||
"history_edit_button": "Upravit záznam",
|
||||
"total_cost": "Celkové náklady",
|
||||
"times_performed": "Provedeno",
|
||||
"older_entries": "starší",
|
||||
"open_in_panel": "Otevřít v panelu Údržba",
|
||||
"skip_reason": "Důvod přeskočení (volitelné)",
|
||||
"reset_to_date": "Resetovat poslední provedení na",
|
||||
"delete_task_confirm": "Smazat tento úkol a jeho historii?",
|
||||
"delete_object_confirm": "Smazat tento objekt a všechny jeho úkoly?",
|
||||
"loading": "Načítání…",
|
||||
"archive": "Archivovat",
|
||||
"undo": "Zpět",
|
||||
"task_archived": "Úkol archivován",
|
||||
"object_archived": "Objekt archivován",
|
||||
"unarchive": "Obnovit z archivu",
|
||||
"archived": "Archivováno",
|
||||
"show_archived": "Zobrazit archivované",
|
||||
"hide_archived": "Skrýt archivované",
|
||||
"confirm_archive_object": "Archivovat tento objekt a jeho úkoly? Historie zůstane zachována a lze ji později obnovit.",
|
||||
"settings_archive": "Archiv a uchovávání",
|
||||
"settings_archive_desc": "Odkládejte dokončené jednorázové úkoly bez mazání. Archivované položky jsou skryté a neaktivní, ale zachovávají historii a náklady.",
|
||||
"settings_archive_oneoff_days": "Automaticky archivovat dokončené jednorázové úkoly po (dny, 0 = vypnuto)",
|
||||
"settings_delete_archived_oneoff_days": "Automaticky mazat archivované jednorázové úkoly po (dny, 0 = nikdy)",
|
||||
"archive_object": "Archivovat objekt",
|
||||
"unarchive_object": "Obnovit objekt z archivu",
|
||||
"documents": "Dokumenty",
|
||||
"documents_empty": "Zatím žádné dokumenty.",
|
||||
"doc_upload": "Nahrát soubor",
|
||||
"doc_uploading": "Nahrávání…",
|
||||
"doc_add_link": "Přidat odkaz",
|
||||
"doc_link_url": "URL (https://…)",
|
||||
"doc_link_title": "Název (nepovinné)",
|
||||
"doc_open": "Otevřít",
|
||||
"doc_delete_confirm": "Smazat „{name}“?",
|
||||
"doc_too_large": "Soubor je příliš velký (max. 25 MB).",
|
||||
"doc_upload_failed": "Nahrání selhalo.",
|
||||
"completion_photo_optional": "Completion photo (optional)",
|
||||
"add_photo": "Add photo",
|
||||
"uploading": "Uploading…",
|
||||
"remove": "Remove",
|
||||
"doc_deduped": "Již uloženo jinde — sdíleno, bez dalšího místa.",
|
||||
"doc_dup_in_object": "Tento soubor je již připojen k tomuto objektu.",
|
||||
"doc_link_invalid": "Povoleny jsou pouze odkazy http/https.",
|
||||
"doc_cat_manual": "Návod",
|
||||
"doc_cat_warranty": "Záruka",
|
||||
"doc_cat_invoice": "Faktura",
|
||||
"doc_cat_spare_parts": "Náhradní díly",
|
||||
"doc_cat_photo": "Fotografie",
|
||||
"doc_cat_other": "Ostatní",
|
||||
"doc_link_badge": "Odkaz",
|
||||
"doc_storage_title": "Úložiště dokumentů",
|
||||
"doc_storage_saved": "Ušetřeno deduplikací",
|
||||
"doc_storage_refresh": "Obnovit",
|
||||
"doc_download": "Stáhnout",
|
||||
"doc_close": "Zavřít",
|
||||
"doc_camera": "Vyfotit",
|
||||
"doc_drop_hint": "Přetáhněte soubory sem",
|
||||
"doc_task_none": "Žádné dokumenty spojené s tímto úkolem.",
|
||||
"doc_link_existing": "Připojit dokument…",
|
||||
"doc_attach": "Připojit",
|
||||
"doc_unlink": "Odpojit",
|
||||
"doc_page": "Strana",
|
||||
"chart_range_7d": "7d",
|
||||
"chart_range_30d": "30d",
|
||||
"chart_range_90d": "90d",
|
||||
"chart_range_1y": "1r",
|
||||
"chart_since_service": "od poslední údržby",
|
||||
"chart_no_stats": "Pro tuto entitu nejsou dlouhodobé statistiky — zobrazují se pouze hodnoty z údržbových záznamů",
|
||||
"auto_complete_on_recovery": "Automaticky dokončit při zotavení senzoru",
|
||||
"auto_complete_on_recovery_help": "Zaznamená dokončení (nastaví poslední provedení), jakmile se spouštěč sám vyřeší — např. doplněná sůl, vyměněný filtr.",
|
||||
"doc_search": "Hledat dokumenty…",
|
||||
"doc_search_none": "Žádné odpovídající dokumenty",
|
||||
"link_device_optional": "Propojit s existujícím zařízením (volitelné)",
|
||||
"parent_object_optional": "Nadřazený objekt (volitelné)",
|
||||
"parent_none": "(Žádný nadřazený)",
|
||||
"paused": "Pozastaveno",
|
||||
"pause_object": "Pozastavit",
|
||||
"resume_object": "Pokračovat",
|
||||
"pause_until_prompt": "Zmrazit plány tohoto objektu — nic není splatné a nic neupozorňuje, dokud nebude obnoven. Volitelně nastavte datum automatického obnovení.",
|
||||
"pause_until_label": "Pokračovat dne (volitelné)",
|
||||
"object_paused": "Objekt pozastaven",
|
||||
"object_resumed": "Objekt obnoven — plány restartovány",
|
||||
"object_paused_badge": "Pozastaveno",
|
||||
"paused_until_label": "do",
|
||||
"replace_object": "Nahradit…",
|
||||
"replace_object_prompt": "Vyřadit tento objekt a vytvořit nástupce. Historie a náklady zůstávají archivované u starého; úkoly a dokumenty přecházejí na nový, počítadla začínají od nuly.",
|
||||
"replace_name_label": "Název nástupce",
|
||||
"object_replaced": "Objekt nahrazen — nástupce vytvořen",
|
||||
"reading_unit_label": "Jednotka odečtu (např. kWh, m³)",
|
||||
"reading_unit_help": "Zobrazí se vedle zaznamenané hodnoty při dokončení tohoto úkolu.",
|
||||
"reading_value_label": "Hodnota odečtu",
|
||||
"reading_label": "Odečet",
|
||||
"settings_templates_label": "Galerie šablon",
|
||||
"settings_templates_hint": "Odškrtněte šablony, které nikdy nevyužijete — zmizí z výběru „Ze šablony\" (panel i config flow). Nic jiného se nemění; kdykoli je lze znovu zapnout.",
|
||||
"worksheet": "Pracovní list",
|
||||
"worksheet_scan_view": "Naskenujte pro otevření úkolu",
|
||||
"worksheet_scan_complete": "Naskenujte pro dokončení",
|
||||
"worksheet_manual_excerpt": "Výňatek z manuálu",
|
||||
"worksheet_pages": "strany",
|
||||
"worksheet_printed": "Vytištěno",
|
||||
"worksheet_never": "Nikdy"
|
||||
}
|
||||
@@ -0,0 +1,696 @@
|
||||
{
|
||||
"maintenance": "Vedligeholdelse",
|
||||
"objects": "Objekter",
|
||||
"tasks": "Opgaver",
|
||||
"overdue": "Forfalden",
|
||||
"due_soon": "Snart forfalden",
|
||||
"triggered": "Udløst",
|
||||
"trigger_replaced": "Udløser erstattet",
|
||||
"ok": "OK",
|
||||
"all": "Alle",
|
||||
"new_object": "+ Nyt objekt",
|
||||
"templates_from": "Fra skabelon",
|
||||
"templates_title": "Start fra en skabelon",
|
||||
"templates_task_count": "{n} opgaver",
|
||||
"template_created": "Oprettet fra skabelon",
|
||||
"onboard_hint": "Tilføj dit første objekt for at spore vedligeholdelse.",
|
||||
"edit": "Rediger",
|
||||
"duplicate": "Dupliker",
|
||||
"task_duplicated": "Opgave duplikeret",
|
||||
"object_duplicated": "Objekt duplikeret",
|
||||
"delete": "Slet",
|
||||
"add_task": "+ Tilføj opgave",
|
||||
"complete": "Fuldfør",
|
||||
"completed": "Fuldført",
|
||||
"skip": "Spring over",
|
||||
"skipped": "Sprunget over",
|
||||
"missed": "Missed",
|
||||
"reset": "Nulstil",
|
||||
"snooze": "Snooze",
|
||||
"snoozed": "Snoozed",
|
||||
"cancel": "Annuller",
|
||||
"bulk_select": "Vælg",
|
||||
"bulk_select_all": "Vælg alle",
|
||||
"bulk_n_selected": "{n} valgt",
|
||||
"bulk_completed": "{n} opgaver fuldført",
|
||||
"bulk_archived": "{n} opgaver arkiveret",
|
||||
"completing": "Fuldfører…",
|
||||
"interval": "Interval",
|
||||
"warning": "Advarsel",
|
||||
"last_performed": "Senest udført",
|
||||
"next_due": "Næste forfald",
|
||||
"days_until_due": "Dage til forfald",
|
||||
"avg_duration": "Gns. varighed",
|
||||
"trigger": "Udløser",
|
||||
"trigger_type": "Udløsertype",
|
||||
"threshold_above": "Øvre grænse",
|
||||
"threshold_below": "Nedre grænse",
|
||||
"threshold": "Tærskel",
|
||||
"counter": "Tæller",
|
||||
"state_change": "Tilstandsændring",
|
||||
"runtime": "Driftstid",
|
||||
"runtime_hours": "Måldriftstid (timer)",
|
||||
"target_value": "Målværdi",
|
||||
"baseline": "Referenceværdi",
|
||||
"target_changes": "Antal målændringer",
|
||||
"for_minutes": "I (minutter)",
|
||||
"time_based": "Tidsbaseret",
|
||||
"sensor_based": "Sensorbaseret",
|
||||
"manual": "Manuel",
|
||||
"one_time": "Engangs",
|
||||
"weekdays": "Ugedage",
|
||||
"nth_weekday": "N'te ugedag i måneden",
|
||||
"day_of_month": "Dag i måneden",
|
||||
"recurrence_on_days": "Gentag på",
|
||||
"recurrence_occurrence": "Forekomst",
|
||||
"recurrence_weekday": "Ugedag",
|
||||
"recurrence_day": "Dag i måneden (1–31)",
|
||||
"recurrence_last_day": "Last day of the month",
|
||||
"recurrence_business_day": "Business days only (roll back from weekend)",
|
||||
"recurrence_offset": "Offset (days, ±)",
|
||||
"recurrence_offset_help": "Shift the date by ±N days, e.g. -2 = two days before.",
|
||||
"last_day_month": "Last day of month",
|
||||
"last_business_day_month": "Last business day",
|
||||
"ord_1": "1.",
|
||||
"ord_2": "2.",
|
||||
"ord_3": "3.",
|
||||
"ord_4": "4.",
|
||||
"ord_5": "5.",
|
||||
"ord_last": "Sidste",
|
||||
"day_word": "Dag",
|
||||
"interval_value": "Interval",
|
||||
"interval_unit": "Enhed",
|
||||
"unit_days": "Dage",
|
||||
"unit_weeks": "Uger",
|
||||
"unit_months": "Måneder",
|
||||
"unit_years": "År",
|
||||
"due_date": "Forfaldsdato",
|
||||
"cleaning": "Rengøring",
|
||||
"inspection": "Inspektion",
|
||||
"replacement": "Udskiftning",
|
||||
"calibration": "Kalibrering",
|
||||
"service": "Service",
|
||||
"reading": "Aflæsning",
|
||||
"custom": "Tilpasset",
|
||||
"history": "Historik",
|
||||
"cost": "Omkostning",
|
||||
"report_button": "Rapport",
|
||||
"report_title": "Vedligeholdelsesrapport",
|
||||
"report_generated": "Genereret",
|
||||
"report_times_done": "Udført",
|
||||
"report_total_cost": "Samlede omkostninger",
|
||||
"report_every": "hver {n}. {unit}",
|
||||
"report_notes": "Noter",
|
||||
"report_col_type": "Type",
|
||||
"report_col_status": "Status",
|
||||
"report_col_schedule": "Tidsplan",
|
||||
"duration": "Varighed",
|
||||
"both": "Begge",
|
||||
"trigger_val": "Udløserværdi",
|
||||
"complete_title": "Fuldfør: ",
|
||||
"checklist": "Tjekliste",
|
||||
"checklist_steps_optional": "Tjeklistetrin (valgfrit)",
|
||||
"checklist_placeholder": "Rengør filter\nUdskift pakning\nTest tryk",
|
||||
"checklist_help": "Ét trin pr. linje. Maks. 100 elementer.",
|
||||
"err_too_long": "{field}: for lang (maks. {n} tegn)",
|
||||
"err_too_short": "{field}: for kort (min. {n} tegn)",
|
||||
"err_value_too_high": "{field}: for stor (maks. {n})",
|
||||
"err_value_too_low": "{field}: for lille (min. {n})",
|
||||
"err_required": "{field}: påkrævet",
|
||||
"err_wrong_type": "{field}: forkert type (forventet: {type})",
|
||||
"err_invalid_choice": "{field}: ikke en tilladt værdi",
|
||||
"err_invalid_value": "{field}: ugyldig værdi",
|
||||
"feat_schedule_time": "Planlægning efter tidspunkt på dagen",
|
||||
"feat_schedule_time_desc": "Opgaver bliver forfaldne på et bestemt tidspunkt på dagen i stedet for midnat.",
|
||||
"schedule_time_optional": "Forfalder kl. (valgfrit, TT:MM)",
|
||||
"schedule_time_help": "Tom = midnat (standard). HA-tidszone.",
|
||||
"at_time": "kl.",
|
||||
"notes_optional": "Noter (valgfrit)",
|
||||
"cost_optional": "Omkostning (valgfrit)",
|
||||
"duration_minutes": "Varighed i minutter (valgfrit)",
|
||||
"days": "dage",
|
||||
"day": "dag",
|
||||
"today": "I dag",
|
||||
"d_overdue": "d forsinket",
|
||||
"no_tasks": "Ingen vedligeholdelsesopgaver endnu. Opret et objekt for at komme i gang.",
|
||||
"no_tasks_short": "Ingen opgaver",
|
||||
"no_history": "Ingen historikposter endnu.",
|
||||
"show_all": "Vis alle",
|
||||
"cost_duration_chart": "Omkostning og varighed",
|
||||
"installed": "Installeret",
|
||||
"confirm_delete_object": "Slet dette objekt og alle dets opgaver?",
|
||||
"confirm_delete_task": "Slet denne opgave?",
|
||||
"min": "Min",
|
||||
"max": "Maks",
|
||||
"save": "Gem",
|
||||
"saving": "Gemmer…",
|
||||
"edit_task": "Rediger opgave",
|
||||
"new_task": "Ny vedligeholdelsesopgave",
|
||||
"task_name": "Opgavenavn",
|
||||
"maintenance_type": "Vedligeholdelsestype",
|
||||
"priority": "Prioritet",
|
||||
"labels": "Labels",
|
||||
"labels_placeholder": "e.g. safety, seasonal, tenant-visible",
|
||||
"labels_help": "Comma-separated tags for filtering and reporting.",
|
||||
"priority_low": "Lav",
|
||||
"priority_normal": "Normal",
|
||||
"priority_high": "Høj",
|
||||
"schedule_type": "Planlægningstype",
|
||||
"interval_days": "Interval (dage)",
|
||||
"warning_days": "Advarselsdage",
|
||||
"earliest_completion_days": "Earliest completion (days before due)",
|
||||
"earliest_completion_days_help": "Leave empty to allow completing any time. 0 = only on/after the due date.",
|
||||
"last_performed_optional": "Senest udført (valgfrit)",
|
||||
"interval_anchor": "Intervalanker",
|
||||
"anchor_completion": "Fra fuldførelsesdato",
|
||||
"anchor_planned": "Fra planlagt dato (ingen drift)",
|
||||
"edit_object": "Rediger objekt",
|
||||
"name": "Navn",
|
||||
"manufacturer_optional": "Producent (valgfrit)",
|
||||
"model_optional": "Model (valgfrit)",
|
||||
"serial_number_optional": "Serienummer (valgfrit)",
|
||||
"serial_number_label": "S/N",
|
||||
"documentation_url_label": "Manual",
|
||||
"object_notes_label": "Noter",
|
||||
"sort_due_date": "Forfaldsdato",
|
||||
"sort_object": "Objektnavn",
|
||||
"sort_type": "Type",
|
||||
"sort_task_name": "Opgavenavn",
|
||||
"all_objects": "Alle objekter",
|
||||
"tasks_lower": "opgaver",
|
||||
"no_tasks_yet": "Ingen opgaver endnu",
|
||||
"add_first_task": "Tilføj første opgave",
|
||||
"trigger_configuration": "Udløserkonfiguration",
|
||||
"entity_id": "Enheds-ID",
|
||||
"comma_separated": "kommasepareret",
|
||||
"entity_logic": "Enhedslogik",
|
||||
"entity_logic_any": "Enhver enhed udløser",
|
||||
"entity_logic_all": "Alle enheder skal udløse",
|
||||
"entities": "enheder",
|
||||
"attribute_optional": "Attribut (valgfrit, tom = tilstand)",
|
||||
"use_entity_state": "Brug enhedstilstand (ingen attribut)",
|
||||
"trigger_above": "Udløs over",
|
||||
"trigger_below": "Udløs under",
|
||||
"for_at_least_minutes": "I mindst (minutter)",
|
||||
"safety_interval_days": "Sikkerhedsinterval (dage, valgfrit)",
|
||||
"safety_interval": "Sikkerhedsinterval (valgfrit)",
|
||||
"delta_mode": "Delta-tilstand",
|
||||
"from_state_optional": "Fra tilstand (valgfrit)",
|
||||
"to_state_optional": "Til tilstand (valgfrit)",
|
||||
"documentation_url_optional": "Dokumentations-URL (valgfrit)",
|
||||
"object_notes_optional": "Noter (valgfrit)",
|
||||
"nfc_tag_id_optional": "NFC-tag-ID (valgfrit)",
|
||||
"nfc_tags_empty_help": "Ingen NFC-tags registreret i Home Assistant endnu.",
|
||||
"nfc_tags_open_settings": "Åbn tag-indstillinger",
|
||||
"nfc_tags_refresh": "Opdater",
|
||||
"environmental_entity_optional": "Miljøsensor (valgfrit)",
|
||||
"environmental_entity_helper": "f.eks. sensor.outdoor_temperature — justerer intervallet baseret på miljøforhold",
|
||||
"environmental_attribute_optional": "Miljøattribut (valgfrit)",
|
||||
"nfc_tag_id": "NFC-tag-ID",
|
||||
"nfc_linked": "NFC-tag tilknyttet",
|
||||
"nfc_link_hint": "Klik for at tilknytte NFC-tag",
|
||||
"responsible_user": "Ansvarlig bruger",
|
||||
"shared_with": "Shared with (rotation)",
|
||||
"shared_with_help": "Pick multiple people to share this task; the responsible person rotates on each completion.",
|
||||
"rotation_strategy": "Rotation",
|
||||
"rotation_none": "No rotation",
|
||||
"rotation_round_robin": "Round-robin",
|
||||
"rotation_least_completed": "Least completed",
|
||||
"rotation_random": "Random",
|
||||
"no_user_assigned": "(Ingen bruger tildelt)",
|
||||
"all_users": "Alle brugere",
|
||||
"my_tasks": "Mine opgaver",
|
||||
"tab_calendar": "Kalender",
|
||||
"cal_no_events": "Ingen vedligeholdelse",
|
||||
"cal_window_7": "7 dage",
|
||||
"cal_window_14": "14 dage",
|
||||
"cal_window_30": "30 dage",
|
||||
"cal_window_365": "1 år",
|
||||
"cal_every_n_days": "hver {n}. dag",
|
||||
"cal_source_time": "Tidsbaseret",
|
||||
"cal_source_time_adaptive": "Tidsbaseret (adaptiv)",
|
||||
"cal_source_sensor": "Sensorbaseret",
|
||||
"cal_predicted": "forudsagt",
|
||||
"cal_confidence_high": "høj sikkerhed",
|
||||
"cal_confidence_medium": "mellem sikkerhed",
|
||||
"cal_confidence_low": "lav sikkerhed",
|
||||
"budget_monthly": "Månedligt budget",
|
||||
"budget_yearly": "Årligt budget",
|
||||
"groups": "Grupper",
|
||||
"new_group": "Ny gruppe",
|
||||
"edit_group": "Rediger gruppe",
|
||||
"no_groups": "Ingen grupper endnu",
|
||||
"delete_group": "Slet gruppe",
|
||||
"delete_group_confirm": "Slet gruppen '{name}'?",
|
||||
"group_select_tasks": "Vælg opgaver",
|
||||
"group_name_required": "Navn er påkrævet",
|
||||
"description_optional": "Beskrivelse (valgfrit)",
|
||||
"selected": "Valgte",
|
||||
"loading_chart": "Indlæser diagramdata...",
|
||||
"hide_outliers": "Skjul outliers (sensorfejl)",
|
||||
"was_maintenance_needed": "Var denne vedligeholdelse nødvendig?",
|
||||
"feedback_needed": "Nødvendig",
|
||||
"feedback_not_needed": "Ikke nødvendig",
|
||||
"feedback_not_sure": "Usikker",
|
||||
"suggested_interval": "Foreslået interval",
|
||||
"apply_suggestion": "Anvend",
|
||||
"reanalyze": "Analyser igen",
|
||||
"reanalyze_result": "Ny analyse",
|
||||
"reanalyze_insufficient_data": "Ikke nok data til at give en anbefaling",
|
||||
"data_points": "datapunkter",
|
||||
"dismiss_suggestion": "Afvis",
|
||||
"confidence_low": "Lav",
|
||||
"confidence_medium": "Mellem",
|
||||
"confidence_high": "Høj",
|
||||
"recommended": "anbefalet",
|
||||
"seasonal_awareness": "Sæsonbevidsthed",
|
||||
"edit_seasonal_overrides": "Rediger sæsonfaktorer",
|
||||
"seasonal_overrides_title": "Sæsonfaktorer (tilsidesæt)",
|
||||
"seasonal_overrides_hint": "Faktor pr. måned (0.1–5.0). Tom = lært automatisk.",
|
||||
"seasonal_override_invalid": "Ugyldig værdi",
|
||||
"seasonal_override_range": "Faktor skal være mellem 0.1 og 5.0",
|
||||
"clear_all": "Ryd alle",
|
||||
"seasonal_chart_title": "Sæsonfaktorer",
|
||||
"seasonal_learned": "Lært",
|
||||
"seasonal_manual": "Manuel",
|
||||
"month_jan": "Jan",
|
||||
"month_feb": "Feb",
|
||||
"month_mar": "Mar",
|
||||
"month_apr": "Apr",
|
||||
"month_may": "Maj",
|
||||
"month_jun": "Jun",
|
||||
"month_jul": "Jul",
|
||||
"month_aug": "Aug",
|
||||
"month_sep": "Sep",
|
||||
"month_oct": "Okt",
|
||||
"month_nov": "Nov",
|
||||
"month_dec": "Dec",
|
||||
"sensor_prediction": "Sensorforudsigelse",
|
||||
"degradation_trend": "Tendens",
|
||||
"trend_rising": "Stigende",
|
||||
"trend_falling": "Faldende",
|
||||
"trend_stable": "Stabil",
|
||||
"trend_insufficient_data": "Utilstrækkelige data",
|
||||
"days_until_threshold": "Dage til tærskel",
|
||||
"threshold_exceeded": "Tærskel overskredet",
|
||||
"environmental_adjustment": "Miljøfaktor",
|
||||
"sensor_prediction_urgency": "Sensor forudsiger tærskel om ~{days} dage",
|
||||
"day_short": "d",
|
||||
"weibull_reliability_curve": "Pålidelighedskurve",
|
||||
"weibull_failure_probability": "Fejlsandsynlighed",
|
||||
"weibull_r_squared": "Tilpasning R²",
|
||||
"beta_early_failures": "Tidlige fejl",
|
||||
"beta_random_failures": "Tilfældige fejl",
|
||||
"beta_wear_out": "Slitage",
|
||||
"beta_highly_predictable": "Meget forudsigelig",
|
||||
"confidence_interval": "Konfidensinterval",
|
||||
"confidence_conservative": "Konservativ",
|
||||
"confidence_aggressive": "Optimistisk",
|
||||
"current_interval_marker": "Nuværende interval",
|
||||
"recommended_marker": "Anbefalet",
|
||||
"characteristic_life": "Karakteristisk levetid",
|
||||
"chart_mini_sparkline": "Tendensdiagram",
|
||||
"chart_history": "Omkostnings- og varighedshistorik",
|
||||
"chart_seasonal": "Sæsonfaktorer, 12 måneder",
|
||||
"chart_weibull": "Weibull-pålidelighedskurve",
|
||||
"chart_sparkline": "Diagram over sensorudløserværdi",
|
||||
"days_progress": "Dagsforløb",
|
||||
"qr_code": "QR-kode",
|
||||
"qr_generating": "Genererer QR-kode…",
|
||||
"qr_error": "Kunne ikke generere QR-kode.",
|
||||
"qr_error_no_url": "Ingen HA-URL konfigureret. Angiv en ekstern eller intern URL under Indstillinger → System → Netværk.",
|
||||
"save_error": "Kunne ikke gemme. Prøv igen.",
|
||||
"qr_print": "Udskriv",
|
||||
"qr_download": "Download SVG",
|
||||
"qr_action": "Handling ved scanning",
|
||||
"qr_action_view": "Vis vedligeholdelsesoplysninger",
|
||||
"qr_action_complete": "Marker vedligeholdelse som fuldført",
|
||||
"qr_url_mode": "Linktype",
|
||||
"qr_mode_companion": "Companion-app",
|
||||
"qr_mode_local": "Lokal (mDNS)",
|
||||
"qr_mode_server": "Server-URL",
|
||||
"overview": "Oversigt",
|
||||
"analysis": "Analyse",
|
||||
"recent_activities": "Seneste aktiviteter",
|
||||
"search_notes": "Søg i noter",
|
||||
"avg_cost": "Gns. omkostning",
|
||||
"no_advanced_features": "Ingen avancerede funktioner aktiveret",
|
||||
"no_advanced_features_hint": "Aktiver „Adaptive intervaller” eller „Sæsonmønstre” i integrationsindstillingerne for at se analysedata her.",
|
||||
"analysis_not_enough_data": "Ikke nok data til analyse endnu.",
|
||||
"analysis_not_enough_data_hint": "Weibull-analyse kræver mindst 5 fuldførte vedligeholdelser; sæsonmønstre bliver synlige efter 6+ datapunkter pr. måned.",
|
||||
"analysis_manual_task_hint": "Manuelle opgaver uden et interval genererer ikke analysedata.",
|
||||
"completions": "fuldførelser",
|
||||
"current": "Nuværende",
|
||||
"shorter": "Kortere",
|
||||
"longer": "Længere",
|
||||
"normal": "Normal",
|
||||
"disabled": "Deaktiveret",
|
||||
"compound_logic": "Sammensat logik",
|
||||
"compound": "Sammensat (flere betingelser)",
|
||||
"compound_logic_and": "OG — alle betingelser skal udløses",
|
||||
"compound_logic_or": "ELLER — en betingelse er nok",
|
||||
"compound_help": "Kombiner flere sensorbetingelser til én udløser.",
|
||||
"compound_no_conditions": "Ingen betingelser endnu — tilføj mindst én.",
|
||||
"compound_add_condition": "Tilføj betingelse",
|
||||
"compound_condition": "Betingelse",
|
||||
"compound_remove_condition": "Fjern betingelse",
|
||||
"card_title": "Titel",
|
||||
"card_show_header": "Vis overskrift med statistik",
|
||||
"card_show_actions": "Vis handlingsknapper",
|
||||
"card_compact": "Kompakt tilstand",
|
||||
"card_max_items": "Maks. elementer (0 = alle)",
|
||||
"card_filter_status": "Filtrer efter status",
|
||||
"card_filter_status_help": "Tom = vis alle statusser.",
|
||||
"card_filter_objects": "Filtrer efter objekter",
|
||||
"card_filter_objects_help": "Tom = vis alle objekter.",
|
||||
"card_filter_entities": "Filtrer efter enheder (entity_ids)",
|
||||
"card_filter_entities_help": "Vælg sensor- / binary_sensor-enheder fra denne integration. Tom = alle.",
|
||||
"card_loading_objects": "Indlæser objekter…",
|
||||
"card_load_error": "Kunne ikke indlæse objekter — tjek WebSocket-forbindelsen.",
|
||||
"card_no_tasks_title": "Ingen vedligeholdelsesopgaver endnu",
|
||||
"card_no_tasks_cta": "→ Opret en i Vedligeholdelse-panelet",
|
||||
"no_objects": "Ingen objekter endnu.",
|
||||
"action_error": "Handlingen mislykkedes. Prøv igen.",
|
||||
"area_id_optional": "Område (valgfrit)",
|
||||
"installation_date_optional": "Installationsdato (valgfrit)",
|
||||
"warranty_expiry_optional": "Garantiens udløb (valgfrit)",
|
||||
"warranty": "Garanti",
|
||||
"warranty_valid_until": "gyldig indtil {date}",
|
||||
"warranty_expires_in": "udløber om {days} dage",
|
||||
"warranty_expired": "udløbet",
|
||||
"cal_past_windows": "Tidligere vinduer",
|
||||
"cal_forward_windows": "Kommende vinduer",
|
||||
"history_edit_title": "Rediger historikpost",
|
||||
"history_edit_timestamp": "Tidsstempel",
|
||||
"manufacturer": "Producent",
|
||||
"model": "Model",
|
||||
"area": "Område",
|
||||
"actions": "Handlinger",
|
||||
"view_mode_label": "Visning",
|
||||
"view_cards": "Kortvisning",
|
||||
"view_table": "Tabelvisning",
|
||||
"objects_table_columns_label": "Kolonner i objekttabel",
|
||||
"objects_table_columns_hint": "Vælg, hvilke kolonner der vises i objekttabelvisningen.",
|
||||
"custom_icon_optional": "Ikon (valgfrit, f.eks. mdi:wrench)",
|
||||
"task_enabled": "Opgave aktiveret",
|
||||
"skip_reason_prompt": "Spring denne opgave over?",
|
||||
"reason_optional": "Årsag (valgfrit)",
|
||||
"reset_date_prompt": "Marker opgave som udført?",
|
||||
"reset_date_optional": "Dato for seneste udførelse (valgfrit, standard er i dag)",
|
||||
"notes_label": "Noter",
|
||||
"documentation_label": "Dokumentation",
|
||||
"no_nfc_tag": "— Intet tag —",
|
||||
"dashboard": "Oversigt",
|
||||
"tab_today": "I dag",
|
||||
"palette_placeholder": "Søg objekter og opgaver…",
|
||||
"palette_no_results": "Ingen match",
|
||||
"palette_hint": "↑↓ naviger · Enter åbn · Esc luk",
|
||||
"today_all_caught_up": "Alt er klaret! Intet forfalder i denne uge.",
|
||||
"today_overdue": "Forfaldne",
|
||||
"today_due_today": "Forfalder i dag",
|
||||
"today_this_week": "Denne uge",
|
||||
"settings": "Indstillinger",
|
||||
"settings_features": "Avancerede funktioner",
|
||||
"settings_features_desc": "Aktiver eller deaktiver avancerede funktioner. Deaktivering skjuler dem fra brugerfladen, men sletter ikke data.",
|
||||
"feat_adaptive": "Adaptiv planlægning",
|
||||
"feat_adaptive_desc": "Lær optimale intervaller fra vedligeholdelseshistorik",
|
||||
"feat_predictions": "Sensorforudsigelser",
|
||||
"feat_predictions_desc": "Forudsig udløsningsdatoer fra sensornedbrydning",
|
||||
"feat_seasonal": "Sæsonjusteringer",
|
||||
"feat_seasonal_desc": "Juster intervaller baseret på sæsonmønstre",
|
||||
"feat_environmental": "Miljøkorrelation",
|
||||
"feat_environmental_desc": "Korreler intervaller med temperatur/luftfugtighed",
|
||||
"feat_budget": "Budgetopfølgning",
|
||||
"feat_budget_desc": "Følg månedlige og årlige vedligeholdelsesudgifter",
|
||||
"feat_groups": "Opgavegrupper",
|
||||
"feat_groups_desc": "Organiser opgaver i logiske grupper",
|
||||
"feat_checklists": "Tjeklister",
|
||||
"feat_checklists_desc": "Procedurer i flere trin til fuldførelse af opgaver",
|
||||
"settings_general": "Generelt",
|
||||
"settings_default_warning": "Standard advarselsdage",
|
||||
"settings_panel_enabled": "Sidepanel",
|
||||
"settings_panel_title": "Sidepanelets titel",
|
||||
"settings_notifications": "Notifikationer",
|
||||
"settings_notify_service": "Notifikationstjeneste",
|
||||
"test_notification": "Testnotifikation",
|
||||
"send_test": "Send test",
|
||||
"testing": "Sender…",
|
||||
"test_notification_success": "Testnotifikation sendt",
|
||||
"test_notification_failed": "Testnotifikation mislykkedes",
|
||||
"settings_notify_due_soon": "Notificer ved snart forfalden",
|
||||
"settings_notify_overdue": "Notificer ved forfalden",
|
||||
"settings_notify_triggered": "Notificer ved udløst",
|
||||
"settings_interval_hours": "Gentagelsesinterval (timer, 0 = én gang)",
|
||||
"settings_quiet_hours": "Stilletimer",
|
||||
"settings_quiet_start": "Start",
|
||||
"settings_quiet_end": "Slut",
|
||||
"settings_max_per_day": "Maks. notifikationer pr. dag (0 = ubegrænset)",
|
||||
"settings_bundling": "Saml notifikationer",
|
||||
"settings_bundle_threshold": "Samlingstærskel",
|
||||
"settings_reminder_leads": "Extra reminders (days before due)",
|
||||
"settings_reminder_leads_hint": "Comma-separated lead times, e.g. 14, 3, 0 — one extra reminder fires on each matching day. Empty = off.",
|
||||
"settings_actions": "Mobile handlingsknapper",
|
||||
"settings_action_complete": "Vis 'Fuldfør'-knap",
|
||||
"settings_action_skip": "Vis 'Spring over'-knap",
|
||||
"settings_action_snooze": "Vis 'Udsæt'-knap",
|
||||
"settings_weekly_digest": "Ugentlig oversigt",
|
||||
"settings_weekly_digest_hint": "Én samlet notifikation mandag morgen, når opgaver forfalder.",
|
||||
"settings_warranty_reminder": "Warranty expiry reminder",
|
||||
"settings_warranty_reminder_days": "Days before expiry",
|
||||
"settings_warranty_reminder_hint": "Notify once when an object's warranty is this many days from expiring.",
|
||||
"settings_snooze_hours": "Udsættelsesvarighed (timer)",
|
||||
"settings_budget": "Budget",
|
||||
"settings_currency": "Valuta",
|
||||
"settings_budget_monthly": "Månedligt budget",
|
||||
"settings_budget_yearly": "Årligt budget",
|
||||
"settings_budget_alerts": "Budgetadvarsler",
|
||||
"settings_budget_threshold": "Advarselstærskel (%)",
|
||||
"settings_import_export": "Import / Eksport",
|
||||
"settings_export_json": "Eksporter JSON",
|
||||
"settings_export_yaml": "Eksporter YAML",
|
||||
"settings_export_csv": "Eksporter CSV",
|
||||
"settings_import_csv": "Importer CSV",
|
||||
"settings_import_placeholder": "Indsæt JSON- eller CSV-indhold her…",
|
||||
"settings_import_btn": "Importer",
|
||||
"settings_import_success": "{count} objekter importeret.",
|
||||
"settings_export_success": "Eksport downloadet.",
|
||||
"settings_saved": "Indstilling gemt.",
|
||||
"settings_include_history": "Inkluder historik",
|
||||
"sort_alphabetical": "Alfabetisk",
|
||||
"sort_due_soonest": "Forfalder først",
|
||||
"sort_task_count": "Antal opgaver",
|
||||
"sort_area": "Område",
|
||||
"sort_assigned_user": "Tildelt bruger",
|
||||
"sort_group": "Gruppe",
|
||||
"groupby_none": "Ingen gruppering",
|
||||
"groupby_area": "Efter område",
|
||||
"groupby_group": "Efter gruppe",
|
||||
"groupby_user": "Efter bruger",
|
||||
"filter_label": "Filter",
|
||||
"user_label": "Bruger",
|
||||
"sort_label": "Sortering",
|
||||
"group_by_label": "Grupper efter",
|
||||
"state_value_help": "Brug HA-tilstandsværdien (normalt med små bogstaver, f.eks. \"on\"/\"off\"). Store/små bogstaver normaliseres ved lagring.",
|
||||
"target_changes_help": "Antal matchende overgange, før udløseren aktiveres (standard: 1).",
|
||||
"qr_print_title": "Udskriv QR-koder",
|
||||
"qr_print_desc": "Generer en udskriftsklar side med QR-koder, der kan klippes ud og sættes på dit udstyr.",
|
||||
"qr_print_load": "Indlæs objekter",
|
||||
"qr_print_filter": "Filter",
|
||||
"qr_print_objects": "Objekter",
|
||||
"qr_print_actions": "Handlinger",
|
||||
"qr_print_url_mode": "Linktype",
|
||||
"qr_print_estimate": "Anslåede QR-koder",
|
||||
"qr_print_over_limit": "grænsen er 200, indsnævr filteret",
|
||||
"qr_print_generate": "Generer QR-koder",
|
||||
"qr_print_generating": "Genererer…",
|
||||
"qr_print_ready": "QR-koder klar",
|
||||
"qr_print_print_button": "Udskriv",
|
||||
"qr_print_empty": "Intet at generere",
|
||||
"qr_action_skip": "Spring over",
|
||||
"vacation_title": "Ferietilstand",
|
||||
"vacation_active": "aktiv",
|
||||
"vacation_ended": "afsluttet",
|
||||
"vacation_desc": "Planlæg en ferie: notifikationer sættes på pause i perioden plus et bufferantal dage. Du kan vælge bestemte opgaver til igen.",
|
||||
"vacation_enable": "Aktiver ferietilstand",
|
||||
"vacation_start": "Start",
|
||||
"vacation_end": "Slut",
|
||||
"vacation_buffer": "Buffer (dage)",
|
||||
"vacation_exempt_title": "Notificer alligevel under ferie",
|
||||
"vacation_exempt_desc": "Vælg opgaver, der stadig skal notificere under ferien (f.eks. kritisk poolkemi).",
|
||||
"vacation_load_tasks": "Indlæs opgaver",
|
||||
"vacation_preview_btn": "Vis forhåndsvisning",
|
||||
"vacation_preview_affected": "opgaver berørt",
|
||||
"vacation_event_due_soon": "bliver snart forfalden",
|
||||
"vacation_event_overdue": "bliver forfalden",
|
||||
"vacation_event_triggered_est": "sensorudløsning mulig",
|
||||
"vacation_sensor_based": "(sensorbaseret)",
|
||||
"vacation_action_notify": "Notificer alligevel",
|
||||
"vacation_action_unsilence": "Dæmp igen",
|
||||
"vacation_marked_complete": "Markeret som fuldført",
|
||||
"vacation_marked_skip": "Sprunget over",
|
||||
"vacation_end_now": "Afslut ferie nu",
|
||||
"add": "Tilføj",
|
||||
"show_stats": "Vis statistik + grafer",
|
||||
"hide_stats": "Skjul statistik",
|
||||
"adaptive_no_data": "Endnu ikke nok fuldførelseshistorik til adaptiv analyse. Fuldfør denne opgave et par gange mere for at låse op for intervalanbefalinger og pålidelighedsdiagrammer.",
|
||||
"suggestion_applied": "Foreslået interval anvendt",
|
||||
"vacation_mode": "Ferietilstand",
|
||||
"vacation_status_active": "Aktiv nu",
|
||||
"vacation_status_scheduled": "Planlagt",
|
||||
"vacation_status_inactive": "Inaktiv",
|
||||
"vacation_end_now_confirm": "Afslut ferie med det samme?",
|
||||
"vacation_exempt_count": "undtaget",
|
||||
"vacation_advanced": "Avanceret…",
|
||||
"vacation_open_panel": "Åbn i panel",
|
||||
"enable": "Aktiver",
|
||||
"saved": "Gemt",
|
||||
"budget_monthly_set": "Angiv måned",
|
||||
"budget_yearly_set": "Angiv år",
|
||||
"budget_advanced": "Valuta, advarsler…",
|
||||
"budget_open_panel": "Åbn i panel",
|
||||
"groups_empty": "Ingen grupper endnu.",
|
||||
"group_new_placeholder": "Tilføj gruppe…",
|
||||
"group_delete_confirm": "Slet gruppen \"{name}\"?",
|
||||
"groups_manage_tasks": "Administrer opgavetildelinger…",
|
||||
"groups_open_panel": "Åbn i panel",
|
||||
"unassigned": "Ikke tildelt",
|
||||
"no_area": "Intet område",
|
||||
"has_overdue": "Har forfaldne opgaver",
|
||||
"object": "Objekt",
|
||||
"settings_panel_access": "Paneladgang",
|
||||
"settings_panel_access_desc": "Administratorer har altid fuld adgang. For at delegere oprettelse, redigering og sletning til bestemte ikke-administratorer skal du slå dette til og vælge dem nedenfor — alle andre ser kun Fuldfør og Spring over.",
|
||||
"settings_operator_write": "Tillad valgte brugere at oprette, redigere og slette",
|
||||
"settings_operator_write_desc": "Fra: kun administratorer kan ændre indhold. Til: de valgte brugere nedenfor får også fuld adgang.",
|
||||
"no_non_admin_users": "Ingen ikke-administratorbrugere fundet. Tilføj nogle under Indstillinger → Personer.",
|
||||
"owner_label": "Ejer",
|
||||
"feat_completion_actions": "Fuldførelseshandlinger",
|
||||
"feat_completion_actions_desc": "HA-handling pr. opgave ved fuldførelse + hurtig-fuldfør-QR med forudindstillede værdier.",
|
||||
"on_complete_action_title": "Ved fuldførelse: udløs HA-handling (valgfrit)",
|
||||
"on_complete_action_desc": "Kalder en HA-tjeneste, når opgaven fuldføres — f.eks. nulstil en tæller på enheden.",
|
||||
"on_complete_action_service": "Tjeneste",
|
||||
"on_complete_action_target": "Målenhed",
|
||||
"on_complete_action_target_hint": "Bemærk: enhedens domæne skal matche tjenesten — f.eks. virker 'button.press' kun på button.*, 'counter.increment' kun på counter.*, 'input_button.press' kun på input_button.* osv. Ved et misforhold mislykkes handlingen lydløst (HA logger 'Referenced entities ... missing or not currently available').",
|
||||
"on_complete_action_data": "Data (JSON, valgfrit)",
|
||||
"on_complete_action_test": "Valider konfiguration",
|
||||
"on_complete_action_test_success": "✓ Konfiguration gyldig (handlingen udløses kun ved fuldførelse af opgaven)",
|
||||
"on_complete_action_test_failed": "Mislykkedes",
|
||||
"quick_complete_defaults_title": "Standardværdier for hurtig-fuldfør (til QR-scanninger, valgfrit)",
|
||||
"quick_complete_defaults_desc": "Forudindstillede værdier til hurtig-fuldfør-QR-scanninger. Uden disse åbner QR-koden fuldfør-dialogen.",
|
||||
"quick_complete_defaults_notes": "Noter",
|
||||
"quick_complete_defaults_cost": "Omkostning",
|
||||
"quick_complete_defaults_duration": "Varighed (minutter)",
|
||||
"quick_complete_defaults_feedback_none": "Ingen feedback",
|
||||
"quick_complete_defaults_feedback_needed": "Var nødvendig",
|
||||
"quick_complete_defaults_feedback_not_needed": "Ikke nødvendig",
|
||||
"quick_complete_success": "Hurtigt markeret som fuldført",
|
||||
"show_all_objects": "Vis alle objekter",
|
||||
"show_all_tasks": "Ryd filter — vis alle opgaver",
|
||||
"filter_to_overdue": "Filtrer opgavelisten til kun forfaldne",
|
||||
"filter_to_due_soon": "Filtrer opgavelisten til kun snart forfaldne",
|
||||
"filter_to_triggered": "Filtrer opgavelisten til kun udløste",
|
||||
"open_task": "Åbn opgave",
|
||||
"show_details": "Vis historik + statistik",
|
||||
"hide_details": "Skjul detaljer",
|
||||
"history_empty": "Ingen historik endnu.",
|
||||
"history_edit_button": "Rediger post",
|
||||
"total_cost": "Samlet omkostning",
|
||||
"times_performed": "Udført",
|
||||
"older_entries": "ældre",
|
||||
"open_in_panel": "Åbn i Vedligeholdelse-panelet",
|
||||
"skip_reason": "Årsag til at springe over (valgfrit)",
|
||||
"reset_to_date": "Nulstil senest udført til",
|
||||
"delete_task_confirm": "Slet denne opgave og dens historik?",
|
||||
"delete_object_confirm": "Slet dette objekt og alle dets opgaver?",
|
||||
"loading": "Indlæser…",
|
||||
"archive": "Arkivér",
|
||||
"undo": "Fortryd",
|
||||
"task_archived": "Opgave arkiveret",
|
||||
"object_archived": "Objekt arkiveret",
|
||||
"unarchive": "Gendan",
|
||||
"archived": "Arkiveret",
|
||||
"show_archived": "Vis arkiverede",
|
||||
"hide_archived": "Skjul arkiverede",
|
||||
"confirm_archive_object": "Arkivér dette objekt og dets opgaver? Historikken bevares og kan gendannes senere.",
|
||||
"settings_archive": "Arkiv og opbevaring",
|
||||
"settings_archive_desc": "Læg færdige engangsopgaver væk uden at slette dem. Arkiverede elementer er skjult og inaktive, men bevarer historik og omkostninger.",
|
||||
"settings_archive_oneoff_days": "Arkivér automatisk færdige engangsopgaver efter (dage, 0 = fra)",
|
||||
"settings_delete_archived_oneoff_days": "Slet automatisk arkiverede engangsopgaver efter (dage, 0 = aldrig)",
|
||||
"archive_object": "Arkivér objekt",
|
||||
"unarchive_object": "Gendan objekt",
|
||||
"documents": "Dokumenter",
|
||||
"documents_empty": "Ingen dokumenter endnu.",
|
||||
"doc_upload": "Upload fil",
|
||||
"doc_uploading": "Uploader…",
|
||||
"doc_add_link": "Tilføj link",
|
||||
"doc_link_url": "URL (https://…)",
|
||||
"doc_link_title": "Titel (valgfri)",
|
||||
"doc_open": "Åbn",
|
||||
"doc_delete_confirm": "Slet \"{name}\"?",
|
||||
"doc_too_large": "Filen er for stor (maks. 25 MB).",
|
||||
"doc_upload_failed": "Upload mislykkedes.",
|
||||
"completion_photo_optional": "Completion photo (optional)",
|
||||
"add_photo": "Add photo",
|
||||
"uploading": "Uploading…",
|
||||
"remove": "Remove",
|
||||
"doc_deduped": "Allerede gemt et andet sted — delt, ingen ekstra plads.",
|
||||
"doc_dup_in_object": "Denne fil er allerede vedhæftet dette objekt.",
|
||||
"doc_link_invalid": "Kun http/https-links er tilladt.",
|
||||
"doc_cat_manual": "Vejledning",
|
||||
"doc_cat_warranty": "Garanti",
|
||||
"doc_cat_invoice": "Faktura",
|
||||
"doc_cat_spare_parts": "Reservedele",
|
||||
"doc_cat_photo": "Foto",
|
||||
"doc_cat_other": "Andet",
|
||||
"doc_link_badge": "Link",
|
||||
"doc_storage_title": "Dokumentlager",
|
||||
"doc_storage_saved": "Sparet ved deduplikering",
|
||||
"doc_storage_refresh": "Opdater",
|
||||
"doc_download": "Download",
|
||||
"doc_close": "Luk",
|
||||
"doc_camera": "Tag billede",
|
||||
"doc_drop_hint": "Slip filer her",
|
||||
"doc_task_none": "Ingen dokumenter knyttet til denne opgave.",
|
||||
"doc_link_existing": "Knyt et dokument…",
|
||||
"doc_attach": "Knyt",
|
||||
"doc_unlink": "Fjern kobling",
|
||||
"doc_page": "Side",
|
||||
"chart_range_7d": "7d",
|
||||
"chart_range_30d": "30d",
|
||||
"chart_range_90d": "90d",
|
||||
"chart_range_1y": "1å",
|
||||
"chart_since_service": "siden sidste vedligeholdelse",
|
||||
"chart_no_stats": "Ingen langtidsstatistik for denne enhed — viser kun værdier fra vedligeholdelsesposter",
|
||||
"auto_complete_on_recovery": "Fuldfør automatisk når sensoren retter sig",
|
||||
"auto_complete_on_recovery_help": "Registrerer en fuldførelse (sætter senest udført), når udløseren løser sig selv — f.eks. salt påfyldt, filter udskiftet.",
|
||||
"doc_search": "Søg dokumenter…",
|
||||
"doc_search_none": "Ingen matchende dokumenter",
|
||||
"link_device_optional": "Tilknyt eksisterende enhed (valgfrit)",
|
||||
"parent_object_optional": "Overordnet objekt (valgfrit)",
|
||||
"parent_none": "(Intet overordnet)",
|
||||
"paused": "Sat på pause",
|
||||
"pause_object": "Sæt på pause",
|
||||
"resume_object": "Genoptag",
|
||||
"pause_until_prompt": "Frys objektets planer — intet forfalder og intet notificerer, før det genoptages. Angiv eventuelt en dato for automatisk genoptagelse.",
|
||||
"pause_until_label": "Genoptag den (valgfrit)",
|
||||
"object_paused": "Objekt sat på pause",
|
||||
"object_resumed": "Objekt genoptaget — planer genstartet",
|
||||
"object_paused_badge": "På pause",
|
||||
"paused_until_label": "indtil",
|
||||
"replace_object": "Udskift…",
|
||||
"replace_object_prompt": "Pensioner dette objekt og opret en efterfølger. Historik og omkostninger forbliver arkiveret på det gamle; opgaver og dokumenter følger med til det nye, tællere starter forfra.",
|
||||
"replace_name_label": "Efterfølgerens navn",
|
||||
"object_replaced": "Objekt udskiftet — efterfølger oprettet",
|
||||
"reading_unit_label": "Aflæsningsenhed (f.eks. kWh, m³)",
|
||||
"reading_unit_help": "Vises ved siden af den registrerede værdi, når opgaven fuldføres.",
|
||||
"reading_value_label": "Aflæst værdi",
|
||||
"reading_label": "Aflæsning",
|
||||
"settings_templates_label": "Skabelongalleri",
|
||||
"settings_templates_hint": "Fravælg skabeloner, du aldrig får brug for — de forsvinder fra \"Fra skabelon\"-vælgerne (panel og config flow). Intet andet ændres; kan genaktiveres når som helst.",
|
||||
"worksheet": "Arbejdsark",
|
||||
"worksheet_scan_view": "Scan for at åbne opgaven",
|
||||
"worksheet_scan_complete": "Scan for at fuldføre",
|
||||
"worksheet_manual_excerpt": "Uddrag af manualen",
|
||||
"worksheet_pages": "sider",
|
||||
"worksheet_printed": "Udskrevet",
|
||||
"worksheet_never": "Aldrig"
|
||||
}
|
||||
@@ -0,0 +1,696 @@
|
||||
{
|
||||
"maintenance": "Wartung",
|
||||
"objects": "Objekte",
|
||||
"tasks": "Aufgaben",
|
||||
"overdue": "Überfällig",
|
||||
"due_soon": "Bald fällig",
|
||||
"triggered": "Ausgelöst",
|
||||
"trigger_replaced": "Auslöser ersetzt",
|
||||
"ok": "OK",
|
||||
"all": "Alle",
|
||||
"new_object": "+ Neues Objekt",
|
||||
"templates_from": "Aus Vorlage",
|
||||
"templates_title": "Mit einer Vorlage starten",
|
||||
"templates_task_count": "{n} Aufgaben",
|
||||
"template_created": "Aus Vorlage erstellt",
|
||||
"onboard_hint": "Füge dein erstes Objekt hinzu, um mit der Wartung zu starten.",
|
||||
"edit": "Bearbeiten",
|
||||
"duplicate": "Duplizieren",
|
||||
"task_duplicated": "Aufgabe dupliziert",
|
||||
"object_duplicated": "Objekt dupliziert",
|
||||
"delete": "Löschen",
|
||||
"add_task": "+ Aufgabe",
|
||||
"complete": "Erledigt",
|
||||
"completed": "Abgeschlossen",
|
||||
"skip": "Überspringen",
|
||||
"skipped": "Übersprungen",
|
||||
"missed": "Verpasst",
|
||||
"reset": "Zurücksetzen",
|
||||
"snooze": "Schlummern",
|
||||
"snoozed": "Schlummert",
|
||||
"cancel": "Abbrechen",
|
||||
"bulk_select": "Auswählen",
|
||||
"bulk_select_all": "Alle auswählen",
|
||||
"bulk_n_selected": "{n} ausgewählt",
|
||||
"bulk_completed": "{n} Aufgaben erledigt",
|
||||
"bulk_archived": "{n} Aufgaben archiviert",
|
||||
"completing": "Wird erledigt…",
|
||||
"interval": "Intervall",
|
||||
"warning": "Vorwarnung",
|
||||
"last_performed": "Zuletzt durchgeführt",
|
||||
"next_due": "Nächste Fälligkeit",
|
||||
"days_until_due": "Tage bis fällig",
|
||||
"avg_duration": "Ø Dauer",
|
||||
"trigger": "Trigger",
|
||||
"trigger_type": "Trigger-Typ",
|
||||
"threshold_above": "Obergrenze",
|
||||
"threshold_below": "Untergrenze",
|
||||
"threshold": "Schwellwert",
|
||||
"counter": "Zähler",
|
||||
"state_change": "Zustandsänderung",
|
||||
"runtime": "Laufzeit",
|
||||
"runtime_hours": "Ziel-Laufzeit (Stunden)",
|
||||
"target_value": "Zielwert",
|
||||
"baseline": "Nulllinie",
|
||||
"target_changes": "Ziel-Änderungen",
|
||||
"for_minutes": "Für (Minuten)",
|
||||
"time_based": "Zeitbasiert",
|
||||
"sensor_based": "Sensorbasiert",
|
||||
"manual": "Manuell",
|
||||
"one_time": "Einmalig",
|
||||
"weekdays": "Wochentage",
|
||||
"nth_weekday": "N-ter Wochentag im Monat",
|
||||
"day_of_month": "Tag im Monat",
|
||||
"recurrence_on_days": "Wiederholen an",
|
||||
"recurrence_occurrence": "Vorkommen",
|
||||
"recurrence_weekday": "Wochentag",
|
||||
"recurrence_day": "Tag im Monat (1–31)",
|
||||
"recurrence_last_day": "Letzter Tag des Monats",
|
||||
"recurrence_business_day": "Nur Werktage (vom Wochenende vorziehen)",
|
||||
"recurrence_offset": "Versatz (Tage, ±)",
|
||||
"recurrence_offset_help": "Verschiebt das Datum um ±N Tage, z. B. -2 = zwei Tage davor.",
|
||||
"last_day_month": "Letzter Tag des Monats",
|
||||
"last_business_day_month": "Letzter Werktag",
|
||||
"ord_1": "1.",
|
||||
"ord_2": "2.",
|
||||
"ord_3": "3.",
|
||||
"ord_4": "4.",
|
||||
"ord_5": "5.",
|
||||
"ord_last": "Letzter",
|
||||
"day_word": "Tag",
|
||||
"interval_value": "Intervall",
|
||||
"interval_unit": "Einheit",
|
||||
"unit_days": "Tage",
|
||||
"unit_weeks": "Wochen",
|
||||
"unit_months": "Monate",
|
||||
"unit_years": "Jahre",
|
||||
"due_date": "Fälligkeitsdatum",
|
||||
"cleaning": "Reinigung",
|
||||
"inspection": "Inspektion",
|
||||
"replacement": "Austausch",
|
||||
"calibration": "Kalibrierung",
|
||||
"service": "Service",
|
||||
"reading": "Ablesung",
|
||||
"custom": "Benutzerdefiniert",
|
||||
"history": "Verlauf",
|
||||
"cost": "Kosten",
|
||||
"report_button": "Bericht",
|
||||
"report_title": "Wartungsbericht",
|
||||
"report_generated": "Erstellt",
|
||||
"report_times_done": "Erledigt",
|
||||
"report_total_cost": "Gesamtkosten",
|
||||
"report_every": "alle {n} {unit}",
|
||||
"report_notes": "Notizen",
|
||||
"report_col_type": "Typ",
|
||||
"report_col_status": "Status",
|
||||
"report_col_schedule": "Zeitplan",
|
||||
"duration": "Dauer",
|
||||
"both": "Beides",
|
||||
"trigger_val": "Trigger-Wert",
|
||||
"complete_title": "Erledigt: ",
|
||||
"checklist": "Checkliste",
|
||||
"checklist_steps_optional": "Checkliste-Schritte (optional)",
|
||||
"checklist_placeholder": "Filter reinigen\nDichtung ersetzen\nDruck testen",
|
||||
"checklist_help": "Ein Schritt pro Zeile. Max. 100 Einträge.",
|
||||
"err_too_long": "{field}: zu lang (max. {n} Zeichen)",
|
||||
"err_too_short": "{field}: zu kurz (min. {n} Zeichen)",
|
||||
"err_value_too_high": "{field}: zu groß (max. {n})",
|
||||
"err_value_too_low": "{field}: zu klein (min. {n})",
|
||||
"err_required": "{field}: Pflichtfeld",
|
||||
"err_wrong_type": "{field}: falscher Typ (erwartet: {type})",
|
||||
"err_invalid_choice": "{field}: nicht erlaubter Wert",
|
||||
"err_invalid_value": "{field}: ungültiger Wert",
|
||||
"feat_schedule_time": "Uhrzeit-Scheduling",
|
||||
"feat_schedule_time_desc": "Tasks werden zu einer festen Uhrzeit fällig statt um Mitternacht.",
|
||||
"schedule_time_optional": "Fällig um (optional, HH:MM)",
|
||||
"schedule_time_help": "Leer = Mitternacht (Default). HA-Zeitzone.",
|
||||
"at_time": "um",
|
||||
"notes_optional": "Notizen (optional)",
|
||||
"cost_optional": "Kosten (optional)",
|
||||
"duration_minutes": "Dauer in Minuten (optional)",
|
||||
"days": "Tage",
|
||||
"day": "Tag",
|
||||
"today": "Heute",
|
||||
"d_overdue": "T überfällig",
|
||||
"no_tasks": "Keine Wartungsaufgaben vorhanden. Erstellen Sie ein Objekt um zu beginnen.",
|
||||
"no_tasks_short": "Keine Aufgaben",
|
||||
"no_history": "Noch keine Verlaufseinträge.",
|
||||
"show_all": "Alle anzeigen",
|
||||
"cost_duration_chart": "Kosten & Dauer",
|
||||
"installed": "Installiert",
|
||||
"confirm_delete_object": "Dieses Objekt und alle zugehörigen Aufgaben löschen?",
|
||||
"confirm_delete_task": "Diese Aufgabe wirklich löschen?",
|
||||
"min": "Min",
|
||||
"max": "Max",
|
||||
"save": "Speichern",
|
||||
"saving": "Speichern…",
|
||||
"edit_task": "Aufgabe bearbeiten",
|
||||
"new_task": "Neue Wartungsaufgabe",
|
||||
"task_name": "Aufgabenname",
|
||||
"maintenance_type": "Wartungstyp",
|
||||
"priority": "Priorität",
|
||||
"labels": "Labels",
|
||||
"labels_placeholder": "z. B. Sicherheit, Saison, Mieter-sichtbar",
|
||||
"labels_help": "Kommagetrennte Tags zum Filtern und für Berichte.",
|
||||
"priority_low": "Niedrig",
|
||||
"priority_normal": "Normal",
|
||||
"priority_high": "Hoch",
|
||||
"schedule_type": "Planungsart",
|
||||
"interval_days": "Intervall (Tage)",
|
||||
"warning_days": "Warntage",
|
||||
"earliest_completion_days": "Frühester Abschluss (Tage vor Fälligkeit)",
|
||||
"earliest_completion_days_help": "Leer lassen, um jederzeit abschließen zu können. 0 = erst am/nach dem Fälligkeitstag.",
|
||||
"last_performed_optional": "Zuletzt durchgeführt (optional)",
|
||||
"interval_anchor": "Intervall-Anker",
|
||||
"anchor_completion": "Ab Erledigung",
|
||||
"anchor_planned": "Ab geplantem Datum (kein Drift)",
|
||||
"edit_object": "Objekt bearbeiten",
|
||||
"name": "Name",
|
||||
"manufacturer_optional": "Hersteller (optional)",
|
||||
"model_optional": "Modell (optional)",
|
||||
"serial_number_optional": "Seriennummer (optional)",
|
||||
"serial_number_label": "S/N",
|
||||
"documentation_url_label": "Handbuch",
|
||||
"object_notes_label": "Notizen",
|
||||
"sort_due_date": "Fälligkeit",
|
||||
"sort_object": "Objekt-Name",
|
||||
"sort_type": "Typ",
|
||||
"sort_task_name": "Aufgaben-Name",
|
||||
"all_objects": "Alle Objekte",
|
||||
"tasks_lower": "Aufgaben",
|
||||
"no_tasks_yet": "Noch keine Aufgaben",
|
||||
"add_first_task": "Erste Aufgabe hinzufügen",
|
||||
"trigger_configuration": "Trigger-Konfiguration",
|
||||
"entity_id": "Entitäts-ID",
|
||||
"comma_separated": "kommagetrennt",
|
||||
"entity_logic": "Entitäts-Logik",
|
||||
"entity_logic_any": "Beliebige Entität löst aus",
|
||||
"entity_logic_all": "Alle Entitäten müssen auslösen",
|
||||
"entities": "Entitäten",
|
||||
"attribute_optional": "Attribut (optional, leer = Zustand)",
|
||||
"use_entity_state": "Entitäts-Zustand verwenden (kein Attribut)",
|
||||
"trigger_above": "Auslösen wenn über",
|
||||
"trigger_below": "Auslösen wenn unter",
|
||||
"for_at_least_minutes": "Für mindestens (Minuten)",
|
||||
"safety_interval_days": "Sicherheitsintervall (Tage, optional)",
|
||||
"safety_interval": "Sicherheitsintervall (optional)",
|
||||
"delta_mode": "Delta-Modus",
|
||||
"from_state_optional": "Von Zustand (optional)",
|
||||
"to_state_optional": "Zu Zustand (optional)",
|
||||
"documentation_url_optional": "Dokumentation URL (optional)",
|
||||
"object_notes_optional": "Notizen (optional)",
|
||||
"nfc_tag_id_optional": "NFC-Tag-ID (optional)",
|
||||
"nfc_tags_empty_help": "Noch keine NFC-Tags in Home Assistant registriert.",
|
||||
"nfc_tags_open_settings": "Tag-Einstellungen öffnen",
|
||||
"nfc_tags_refresh": "Aktualisieren",
|
||||
"environmental_entity_optional": "Umgebungs-Sensor (optional)",
|
||||
"environmental_entity_helper": "z.B. sensor.aussentemperatur — passt das Intervall an Umgebungswerte an",
|
||||
"environmental_attribute_optional": "Umgebungs-Attribut (optional)",
|
||||
"nfc_tag_id": "NFC-Tag-ID",
|
||||
"nfc_linked": "NFC-Tag verknüpft",
|
||||
"nfc_link_hint": "Klicken um NFC-Tag zu verknüpfen",
|
||||
"responsible_user": "Verantwortlicher Benutzer",
|
||||
"shared_with": "Geteilt mit (Rotation)",
|
||||
"shared_with_help": "Mehrere Personen auswählen, die sich diese Aufgabe teilen; der/die Verantwortliche wechselt bei jedem Abschluss.",
|
||||
"rotation_strategy": "Rotation",
|
||||
"rotation_none": "Keine Rotation",
|
||||
"rotation_round_robin": "Reihum",
|
||||
"rotation_least_completed": "Am wenigsten erledigt",
|
||||
"rotation_random": "Zufällig",
|
||||
"no_user_assigned": "(Kein Benutzer zugewiesen)",
|
||||
"all_users": "Alle Benutzer",
|
||||
"my_tasks": "Meine Aufgaben",
|
||||
"tab_calendar": "Kalender",
|
||||
"cal_no_events": "Keine Wartung",
|
||||
"cal_window_7": "7 Tage",
|
||||
"cal_window_14": "14 Tage",
|
||||
"cal_window_30": "30 Tage",
|
||||
"cal_window_365": "1 Jahr",
|
||||
"cal_every_n_days": "alle {n} Tage",
|
||||
"cal_source_time": "Zeit-basiert",
|
||||
"cal_source_time_adaptive": "Zeit-basiert (adaptiv)",
|
||||
"cal_source_sensor": "Sensor-basiert",
|
||||
"cal_predicted": "vorhergesagt",
|
||||
"cal_confidence_high": "hohe Genauigkeit",
|
||||
"cal_confidence_medium": "mittlere Genauigkeit",
|
||||
"cal_confidence_low": "niedrige Genauigkeit",
|
||||
"budget_monthly": "Monatsbudget",
|
||||
"budget_yearly": "Jahresbudget",
|
||||
"groups": "Gruppen",
|
||||
"new_group": "Neue Gruppe",
|
||||
"edit_group": "Gruppe bearbeiten",
|
||||
"no_groups": "Keine Gruppen vorhanden",
|
||||
"delete_group": "Gruppe löschen",
|
||||
"delete_group_confirm": "Gruppe '{name}' wirklich löschen?",
|
||||
"group_select_tasks": "Aufgaben auswählen",
|
||||
"group_name_required": "Name erforderlich",
|
||||
"description_optional": "Beschreibung (optional)",
|
||||
"selected": "Ausgewählt",
|
||||
"loading_chart": "Daten werden geladen...",
|
||||
"hide_outliers": "Ausreißer ausblenden (Messfehler)",
|
||||
"was_maintenance_needed": "War diese Wartung nötig?",
|
||||
"feedback_needed": "Nötig",
|
||||
"feedback_not_needed": "Nicht nötig",
|
||||
"feedback_not_sure": "Unsicher",
|
||||
"suggested_interval": "Empfohlenes Intervall",
|
||||
"apply_suggestion": "Übernehmen",
|
||||
"reanalyze": "Neu analysieren",
|
||||
"reanalyze_result": "Neue Analyse",
|
||||
"reanalyze_insufficient_data": "Nicht genügend Daten für eine Empfehlung",
|
||||
"data_points": "Datenpunkte",
|
||||
"dismiss_suggestion": "Verwerfen",
|
||||
"confidence_low": "Niedrig",
|
||||
"confidence_medium": "Mittel",
|
||||
"confidence_high": "Hoch",
|
||||
"recommended": "empfohlen",
|
||||
"seasonal_awareness": "Saisonale Anpassung",
|
||||
"edit_seasonal_overrides": "Saison-Faktoren bearbeiten",
|
||||
"seasonal_overrides_title": "Saisonale Faktoren (Override)",
|
||||
"seasonal_overrides_hint": "Faktor pro Monat (0.1–5.0). Leer = automatisch gelernt.",
|
||||
"seasonal_override_invalid": "Ungültiger Wert",
|
||||
"seasonal_override_range": "Faktor muss zwischen 0.1 und 5.0 liegen",
|
||||
"clear_all": "Alle zurücksetzen",
|
||||
"seasonal_chart_title": "Saisonale Faktoren",
|
||||
"seasonal_learned": "Gelernt",
|
||||
"seasonal_manual": "Manuell",
|
||||
"month_jan": "Jan",
|
||||
"month_feb": "Feb",
|
||||
"month_mar": "Mär",
|
||||
"month_apr": "Apr",
|
||||
"month_may": "Mai",
|
||||
"month_jun": "Jun",
|
||||
"month_jul": "Jul",
|
||||
"month_aug": "Aug",
|
||||
"month_sep": "Sep",
|
||||
"month_oct": "Okt",
|
||||
"month_nov": "Nov",
|
||||
"month_dec": "Dez",
|
||||
"sensor_prediction": "Sensorvorhersage",
|
||||
"degradation_trend": "Trend",
|
||||
"trend_rising": "Steigend",
|
||||
"trend_falling": "Fallend",
|
||||
"trend_stable": "Stabil",
|
||||
"trend_insufficient_data": "Unzureichende Daten",
|
||||
"days_until_threshold": "Tage bis Schwellwert",
|
||||
"threshold_exceeded": "Schwellwert überschritten",
|
||||
"environmental_adjustment": "Umgebungsfaktor",
|
||||
"sensor_prediction_urgency": "Sensor prognostiziert Schwellwert in ~{days} Tagen",
|
||||
"day_short": "Tag",
|
||||
"weibull_reliability_curve": "Zuverlässigkeitskurve",
|
||||
"weibull_failure_probability": "Ausfallwahrscheinlichkeit",
|
||||
"weibull_r_squared": "Güte R²",
|
||||
"beta_early_failures": "Frühausfälle",
|
||||
"beta_random_failures": "Zufällige Ausfälle",
|
||||
"beta_wear_out": "Verschleiß",
|
||||
"beta_highly_predictable": "Hochvorhersagbar",
|
||||
"confidence_interval": "Konfidenzintervall",
|
||||
"confidence_conservative": "Konservativ",
|
||||
"confidence_aggressive": "Optimistisch",
|
||||
"current_interval_marker": "Aktuelles Intervall",
|
||||
"recommended_marker": "Empfohlen",
|
||||
"characteristic_life": "Charakteristische Lebensdauer",
|
||||
"chart_mini_sparkline": "Trend-Sparkline",
|
||||
"chart_history": "Kosten- und Dauer-Verlauf",
|
||||
"chart_seasonal": "Saisonfaktoren, 12 Monate",
|
||||
"chart_weibull": "Weibull-Zuverlässigkeitskurve",
|
||||
"chart_sparkline": "Sensor-Triggerwert-Verlauf",
|
||||
"days_progress": "Tagesfortschritt",
|
||||
"qr_code": "QR-Code",
|
||||
"qr_generating": "QR-Code wird generiert…",
|
||||
"qr_error": "QR-Code konnte nicht generiert werden.",
|
||||
"qr_error_no_url": "Keine HA-URL konfiguriert. Bitte unter Einstellungen → System → Netzwerk eine externe oder interne URL setzen.",
|
||||
"save_error": "Fehler beim Speichern. Bitte erneut versuchen.",
|
||||
"qr_print": "Drucken",
|
||||
"qr_download": "SVG herunterladen",
|
||||
"qr_action": "Aktion beim Scannen",
|
||||
"qr_action_view": "Wartungsinfo anzeigen",
|
||||
"qr_action_complete": "Wartung als erledigt markieren",
|
||||
"qr_url_mode": "Link-Typ",
|
||||
"qr_mode_companion": "Companion App",
|
||||
"qr_mode_local": "Lokal (mDNS)",
|
||||
"qr_mode_server": "Server-URL",
|
||||
"overview": "Übersicht",
|
||||
"analysis": "Analyse",
|
||||
"recent_activities": "Letzte Aktivitäten",
|
||||
"search_notes": "Notizen durchsuchen",
|
||||
"avg_cost": "Ø Kosten",
|
||||
"no_advanced_features": "Keine erweiterten Funktionen aktiviert",
|
||||
"no_advanced_features_hint": "Aktiviere „Adaptive Intervalle“ oder „Saisonale Muster“ in den Integrationseinstellungen, um hier Analysedaten zu sehen.",
|
||||
"analysis_not_enough_data": "Noch nicht genügend Daten für die Analyse vorhanden.",
|
||||
"analysis_not_enough_data_hint": "Die Weibull-Analyse benötigt mindestens 5 abgeschlossene Wartungen, saisonale Muster werden nach 6+ Datenpunkten pro Monat sichtbar.",
|
||||
"analysis_manual_task_hint": "Manuelle Aufgaben ohne Intervall erzeugen keine Analysedaten.",
|
||||
"completions": "Abschlüsse",
|
||||
"current": "Aktuell",
|
||||
"shorter": "Kürzer",
|
||||
"longer": "Länger",
|
||||
"normal": "Normal",
|
||||
"disabled": "Deaktiviert",
|
||||
"compound_logic": "Verknüpfungslogik",
|
||||
"compound": "Verknüpft (mehrere Bedingungen)",
|
||||
"compound_logic_and": "UND — alle Bedingungen müssen auslösen",
|
||||
"compound_logic_or": "ODER — eine Bedingung genügt",
|
||||
"compound_help": "Mehrere Sensor-Bedingungen zu einem Auslöser kombinieren.",
|
||||
"compound_no_conditions": "Noch keine Bedingungen — mindestens eine hinzufügen.",
|
||||
"compound_add_condition": "Bedingung hinzufügen",
|
||||
"compound_condition": "Bedingung",
|
||||
"compound_remove_condition": "Bedingung entfernen",
|
||||
"card_title": "Titel",
|
||||
"card_show_header": "Kopfzeile mit Statistiken anzeigen",
|
||||
"card_show_actions": "Aktionsbuttons anzeigen",
|
||||
"card_compact": "Kompaktmodus",
|
||||
"card_max_items": "Max. Einträge (0 = alle)",
|
||||
"card_filter_status": "Nach Status filtern",
|
||||
"card_filter_status_help": "Leer = alle Status zeigen.",
|
||||
"card_filter_objects": "Nach Objekten filtern",
|
||||
"card_filter_objects_help": "Leer = alle Objekte zeigen.",
|
||||
"card_filter_entities": "Nach Entitäten filtern (entity_ids)",
|
||||
"card_filter_entities_help": "Wähle Sensor-/Binary-Sensor-Entitäten dieser Integration. Leer = alle.",
|
||||
"card_loading_objects": "Lade Objekte…",
|
||||
"card_load_error": "Objekte konnten nicht geladen werden — bitte WS-Verbindung prüfen.",
|
||||
"card_no_tasks_title": "Noch keine Wartungsaufgaben",
|
||||
"card_no_tasks_cta": "→ Im Maintenance-Panel anlegen",
|
||||
"no_objects": "Keine Objekte vorhanden.",
|
||||
"action_error": "Aktion fehlgeschlagen. Bitte erneut versuchen.",
|
||||
"area_id_optional": "Bereich (optional)",
|
||||
"installation_date_optional": "Installationsdatum (optional)",
|
||||
"warranty_expiry_optional": "Garantie-Ablauf (optional)",
|
||||
"warranty": "Garantie",
|
||||
"warranty_valid_until": "gültig bis {date}",
|
||||
"warranty_expires_in": "läuft in {days} Tagen ab",
|
||||
"warranty_expired": "abgelaufen",
|
||||
"cal_past_windows": "Vergangene Fenster",
|
||||
"cal_forward_windows": "Zukünftige Fenster",
|
||||
"history_edit_title": "Verlaufseintrag bearbeiten",
|
||||
"history_edit_timestamp": "Zeitstempel",
|
||||
"manufacturer": "Hersteller",
|
||||
"model": "Modell",
|
||||
"area": "Bereich",
|
||||
"actions": "Aktionen",
|
||||
"view_mode_label": "Ansicht",
|
||||
"view_cards": "Kartenansicht",
|
||||
"view_table": "Tabellenansicht",
|
||||
"objects_table_columns_label": "Spalten der Objekt-Tabelle",
|
||||
"objects_table_columns_hint": "Wähle die Spalten für die Tabellenansicht der Objektübersicht.",
|
||||
"custom_icon_optional": "Icon (optional, z.B. mdi:wrench)",
|
||||
"task_enabled": "Aufgabe aktiviert",
|
||||
"skip_reason_prompt": "Aufgabe überspringen?",
|
||||
"reason_optional": "Grund (optional)",
|
||||
"reset_date_prompt": "Aufgabe als ausgeführt markieren?",
|
||||
"reset_date_optional": "Letztes Erledigungs-Datum (optional, Standard: heute)",
|
||||
"notes_label": "Notizen",
|
||||
"documentation_label": "Dokumentation",
|
||||
"no_nfc_tag": "— Kein Tag —",
|
||||
"dashboard": "Dashboard",
|
||||
"tab_today": "Heute",
|
||||
"palette_placeholder": "Objekte und Aufgaben suchen…",
|
||||
"palette_no_results": "Keine Treffer",
|
||||
"palette_hint": "↑↓ Navigieren · Enter Öffnen · Esc Schließen",
|
||||
"today_all_caught_up": "Alles erledigt! Diese Woche steht nichts an.",
|
||||
"today_overdue": "Überfällig",
|
||||
"today_due_today": "Heute fällig",
|
||||
"today_this_week": "Diese Woche",
|
||||
"settings": "Einstellungen",
|
||||
"settings_features": "Erweiterte Funktionen",
|
||||
"settings_features_desc": "Erweiterte Funktionen ein- oder ausschalten. Deaktivieren blendet sie in der Oberfläche aus, löscht aber keine Daten.",
|
||||
"feat_adaptive": "Adaptive Intervalle",
|
||||
"feat_adaptive_desc": "Optimale Intervalle aus Wartungshistorie lernen",
|
||||
"feat_predictions": "Sensorvorhersagen",
|
||||
"feat_predictions_desc": "Trigger-Datum anhand von Sensordegradation vorhersagen",
|
||||
"feat_seasonal": "Saisonale Anpassungen",
|
||||
"feat_seasonal_desc": "Intervalle basierend auf saisonalen Mustern anpassen",
|
||||
"feat_environmental": "Umgebungskorrelation",
|
||||
"feat_environmental_desc": "Intervalle mit Temperatur/Luftfeuchtigkeit korrelieren",
|
||||
"feat_budget": "Budgetverfolgung",
|
||||
"feat_budget_desc": "Monatliche und jährliche Wartungsausgaben verfolgen",
|
||||
"feat_groups": "Aufgabengruppen",
|
||||
"feat_groups_desc": "Aufgaben in logische Gruppen organisieren",
|
||||
"feat_checklists": "Checklisten",
|
||||
"feat_checklists_desc": "Mehrstufige Verfahren zur Aufgabenerlediung",
|
||||
"settings_general": "Allgemein",
|
||||
"settings_default_warning": "Standard-Warntage",
|
||||
"settings_panel_enabled": "Seitenleisten-Panel",
|
||||
"settings_panel_title": "Panel-Titel",
|
||||
"settings_notifications": "Benachrichtigungen",
|
||||
"settings_notify_service": "Benachrichtigungsdienst",
|
||||
"test_notification": "Test-Benachrichtigung",
|
||||
"send_test": "Test senden",
|
||||
"testing": "Sende…",
|
||||
"test_notification_success": "Test-Benachrichtigung gesendet",
|
||||
"test_notification_failed": "Test-Benachrichtigung fehlgeschlagen",
|
||||
"settings_notify_due_soon": "Bei baldiger Fälligkeit benachrichtigen",
|
||||
"settings_notify_overdue": "Bei Überfälligkeit benachrichtigen",
|
||||
"settings_notify_triggered": "Bei Auslösung benachrichtigen",
|
||||
"settings_interval_hours": "Wiederholungsintervall (Stunden, 0 = einmalig)",
|
||||
"settings_quiet_hours": "Ruhezeiten",
|
||||
"settings_quiet_start": "Beginn",
|
||||
"settings_quiet_end": "Ende",
|
||||
"settings_max_per_day": "Max. Benachrichtigungen pro Tag (0 = unbegrenzt)",
|
||||
"settings_bundling": "Benachrichtigungen bündeln",
|
||||
"settings_bundle_threshold": "Bündelungsschwelle",
|
||||
"settings_reminder_leads": "Zusätzliche Erinnerungen (Tage vor Fälligkeit)",
|
||||
"settings_reminder_leads_hint": "Kommagetrennte Vorlaufzeiten, z. B. 14, 3, 0 — am jeweiligen Tag wird eine zusätzliche Erinnerung gesendet. Leer = aus.",
|
||||
"settings_actions": "Mobile Aktionsbuttons",
|
||||
"settings_action_complete": "\"Erledigt\"-Button anzeigen",
|
||||
"settings_action_skip": "\"Überspringen\"-Button anzeigen",
|
||||
"settings_action_snooze": "\"Schlummern\"-Button anzeigen",
|
||||
"settings_weekly_digest": "Wöchentliche Zusammenfassung",
|
||||
"settings_weekly_digest_hint": "Eine Zusammenfassungs-Benachrichtigung am Montagmorgen, wenn Aufgaben anstehen.",
|
||||
"settings_warranty_reminder": "Garantie-Ablauf-Erinnerung",
|
||||
"settings_warranty_reminder_days": "Tage vor Ablauf",
|
||||
"settings_warranty_reminder_hint": "Einmal benachrichtigen, wenn die Garantie eines Objekts so viele Tage vor Ablauf steht.",
|
||||
"settings_snooze_hours": "Schlummerdauer (Stunden)",
|
||||
"settings_budget": "Budget",
|
||||
"settings_currency": "Währung",
|
||||
"settings_budget_monthly": "Monatsbudget",
|
||||
"settings_budget_yearly": "Jahresbudget",
|
||||
"settings_budget_alerts": "Budget-Warnungen",
|
||||
"settings_budget_threshold": "Warnschwelle (%)",
|
||||
"settings_import_export": "Import / Export",
|
||||
"settings_export_json": "JSON exportieren",
|
||||
"settings_export_yaml": "YAML exportieren",
|
||||
"settings_export_csv": "CSV exportieren",
|
||||
"settings_import_csv": "CSV importieren",
|
||||
"settings_import_placeholder": "JSON- oder CSV-Inhalt hier einfügen…",
|
||||
"settings_import_btn": "Importieren",
|
||||
"settings_import_success": "{count} Objekte erfolgreich importiert.",
|
||||
"settings_export_success": "Export heruntergeladen.",
|
||||
"settings_saved": "Einstellung gespeichert.",
|
||||
"settings_include_history": "Verlauf einbeziehen",
|
||||
"sort_alphabetical": "Alphabetisch",
|
||||
"sort_due_soonest": "Frühestens fällig",
|
||||
"sort_task_count": "Aufgaben-Anzahl",
|
||||
"sort_area": "Bereich",
|
||||
"sort_assigned_user": "Verantwortlicher",
|
||||
"sort_group": "Gruppe",
|
||||
"groupby_none": "Keine Gruppierung",
|
||||
"groupby_area": "Nach Bereich",
|
||||
"groupby_group": "Nach Gruppe",
|
||||
"groupby_user": "Nach Verantwortlichem",
|
||||
"filter_label": "Filter",
|
||||
"user_label": "Benutzer",
|
||||
"sort_label": "Sortierung",
|
||||
"group_by_label": "Gruppieren nach",
|
||||
"state_value_help": "Verwende den HA-Zustandswert (meist kleingeschrieben, z. B. \"on\"/\"off\"). Groß-/Kleinschreibung wird beim Speichern normalisiert.",
|
||||
"target_changes_help": "Anzahl der passenden Übergänge, nach denen der Trigger auslöst (Standard: 1).",
|
||||
"qr_print_title": "QR-Codes drucken",
|
||||
"qr_print_desc": "Erzeuge eine Druckseite mit QR-Codes zum Ausschneiden und Anbringen an den Geräten.",
|
||||
"qr_print_load": "Objekte laden",
|
||||
"qr_print_filter": "Filter",
|
||||
"qr_print_objects": "Objekte",
|
||||
"qr_print_actions": "Aktionen",
|
||||
"qr_print_url_mode": "Link-Typ",
|
||||
"qr_print_estimate": "Geschätzte QR-Codes",
|
||||
"qr_print_over_limit": "Obergrenze ist 200, bitte Filter eingrenzen",
|
||||
"qr_print_generate": "QR-Codes erzeugen",
|
||||
"qr_print_generating": "Erzeuge…",
|
||||
"qr_print_ready": "QR-Codes bereit",
|
||||
"qr_print_print_button": "Drucken",
|
||||
"qr_print_empty": "Keine QR-Codes zu erzeugen",
|
||||
"qr_action_skip": "Überspringen",
|
||||
"vacation_title": "Urlaubsmodus",
|
||||
"vacation_active": "aktiv",
|
||||
"vacation_ended": "beendet",
|
||||
"vacation_desc": "Plane deinen Urlaub: Benachrichtigungen werden während des Zeitraums plus Puffer-Tagen pausiert. Du kannst pro Aufgabe Ausnahmen festlegen.",
|
||||
"vacation_enable": "Urlaubsmodus aktivieren",
|
||||
"vacation_start": "Beginn",
|
||||
"vacation_end": "Ende",
|
||||
"vacation_buffer": "Puffer (Tage)",
|
||||
"vacation_exempt_title": "Trotz Urlaubsmodus benachrichtigen",
|
||||
"vacation_exempt_desc": "Wähle Aufgaben aus, für die auch im Urlaub Benachrichtigungen kommen sollen (z. B. kritische Pool-Chemie).",
|
||||
"vacation_load_tasks": "Aufgaben laden",
|
||||
"vacation_preview_btn": "Vorschau anzeigen",
|
||||
"vacation_preview_affected": "Aufgaben betroffen",
|
||||
"vacation_event_due_soon": "wird bald fällig",
|
||||
"vacation_event_overdue": "wird überfällig",
|
||||
"vacation_event_triggered_est": "Sensor-Trigger möglich",
|
||||
"vacation_sensor_based": "(sensorbasiert)",
|
||||
"vacation_action_notify": "Trotzdem benachrichtigen",
|
||||
"vacation_action_unsilence": "Wieder stummschalten",
|
||||
"vacation_marked_complete": "Als erledigt markiert",
|
||||
"vacation_marked_skip": "Übersprungen",
|
||||
"vacation_end_now": "Urlaub jetzt beenden",
|
||||
"add": "Hinzufügen",
|
||||
"show_stats": "Statistiken + Diagramme",
|
||||
"hide_stats": "Statistiken ausblenden",
|
||||
"adaptive_no_data": "Noch nicht genug Completion-Historie für die adaptive Auswertung. Schließe diese Aufgabe ein paar Mal mehr ab, um Intervall-Empfehlungen und Zuverlässigkeitskurven freizuschalten.",
|
||||
"suggestion_applied": "Vorgeschlagenes Intervall übernommen",
|
||||
"vacation_mode": "Urlaubsmodus",
|
||||
"vacation_status_active": "Aktiv",
|
||||
"vacation_status_scheduled": "Geplant",
|
||||
"vacation_status_inactive": "Inaktiv",
|
||||
"vacation_end_now_confirm": "Urlaub sofort beenden?",
|
||||
"vacation_exempt_count": "ausgenommen",
|
||||
"vacation_advanced": "Erweitert…",
|
||||
"vacation_open_panel": "Im Panel öffnen",
|
||||
"enable": "Aktivieren",
|
||||
"saved": "Gespeichert",
|
||||
"budget_monthly_set": "Monatsbudget setzen",
|
||||
"budget_yearly_set": "Jahresbudget setzen",
|
||||
"budget_advanced": "Währung, Alarme…",
|
||||
"budget_open_panel": "Im Panel öffnen",
|
||||
"groups_empty": "Keine Gruppen vorhanden.",
|
||||
"group_new_placeholder": "Gruppe hinzufügen…",
|
||||
"group_delete_confirm": "Gruppe \"{name}\" löschen?",
|
||||
"groups_manage_tasks": "Aufgaben-Zuordnungen verwalten…",
|
||||
"groups_open_panel": "Im Panel öffnen",
|
||||
"unassigned": "Nicht zugewiesen",
|
||||
"no_area": "Kein Bereich",
|
||||
"has_overdue": "Überfällige Aufgaben",
|
||||
"object": "Objekt",
|
||||
"settings_panel_access": "Panel-Zugriff",
|
||||
"settings_panel_access_desc": "Admins haben immer Vollzugriff. Um Erstellen, Bearbeiten und Löschen an bestimmte Non-Admins zu delegieren, aktiviere dies und wähle sie unten aus — alle anderen sehen nur Abhaken/Überspringen.",
|
||||
"settings_operator_write": "Ausgewählten Nutzern Erstellen, Bearbeiten & Löschen erlauben",
|
||||
"settings_operator_write_desc": "Aus: nur Admins können Inhalte ändern. Ein: die ausgewählten Nutzer unten erhalten ebenfalls Vollzugriff.",
|
||||
"no_non_admin_users": "Keine Non-Admin-User gefunden. Lege welche unter Einstellungen → Personen an.",
|
||||
"owner_label": "Owner",
|
||||
"feat_completion_actions": "Completion-Actions",
|
||||
"feat_completion_actions_desc": "Pro Aufgabe HA-Action beim Abschluss konfigurieren + Quick-Complete-QR mit voreingestellten Werten.",
|
||||
"on_complete_action_title": "Beim Abschluss: HA-Action auslösen (optional)",
|
||||
"on_complete_action_desc": "Ruft beim Erledigen der Aufgabe einen HA-Service auf — z. B. einen Zähler am Gerät zurücksetzen.",
|
||||
"on_complete_action_service": "Service",
|
||||
"on_complete_action_target": "Ziel-Entität",
|
||||
"on_complete_action_target_hint": "Achtung: Domain der Entität muss zum Service passen — z. B. 'button.press' nur für button.*, 'counter.increment' nur für counter.*, 'input_button.press' nur für input_button.* etc. Bei Mismatch fired die Aktion nicht (HA loggt 'Referenced entities ... missing or not currently available').",
|
||||
"on_complete_action_data": "Daten (JSON, optional)",
|
||||
"on_complete_action_test": "Konfiguration prüfen",
|
||||
"on_complete_action_test_success": "✓ Konfiguration gültig (Aktion wird erst beim Abschluss ausgeführt)",
|
||||
"on_complete_action_test_failed": "Konfigurationsfehler",
|
||||
"quick_complete_defaults_title": "Schnell-Abschluss-Standardwerte (für QR-Scans, optional)",
|
||||
"quick_complete_defaults_desc": "Voreingestellte Werte für Schnell-Abschluss-QR. Ohne Werte öffnet der QR den Abschluss-Dialog.",
|
||||
"quick_complete_defaults_notes": "Notizen",
|
||||
"quick_complete_defaults_cost": "Kosten",
|
||||
"quick_complete_defaults_duration": "Dauer (Minuten)",
|
||||
"quick_complete_defaults_feedback_none": "Kein Feedback",
|
||||
"quick_complete_defaults_feedback_needed": "War notwendig",
|
||||
"quick_complete_defaults_feedback_not_needed": "War nicht notwendig",
|
||||
"quick_complete_success": "Schnell als erledigt markiert",
|
||||
"show_all_objects": "Alle Objekte anzeigen",
|
||||
"show_all_tasks": "Filter zurücksetzen — alle Aufgaben anzeigen",
|
||||
"filter_to_overdue": "Auf überfällige Aufgaben filtern",
|
||||
"filter_to_due_soon": "Auf bald fällige Aufgaben filtern",
|
||||
"filter_to_triggered": "Auf ausgelöste Aufgaben filtern",
|
||||
"open_task": "Aufgabe öffnen",
|
||||
"show_details": "Verlauf + Statistik anzeigen",
|
||||
"hide_details": "Details ausblenden",
|
||||
"history_empty": "Noch keine Einträge.",
|
||||
"history_edit_button": "Eintrag bearbeiten",
|
||||
"total_cost": "Gesamtkosten",
|
||||
"times_performed": "Erledigt",
|
||||
"older_entries": "ältere",
|
||||
"open_in_panel": "Im Wartungspanel öffnen",
|
||||
"skip_reason": "Übersprungen-Grund (optional)",
|
||||
"reset_to_date": "last_performed setzen auf",
|
||||
"delete_task_confirm": "Diese Aufgabe und ihren Verlauf löschen?",
|
||||
"delete_object_confirm": "Dieses Objekt und alle seine Aufgaben löschen?",
|
||||
"loading": "Laden…",
|
||||
"archive": "Archivieren",
|
||||
"undo": "Rückgängig",
|
||||
"task_archived": "Aufgabe archiviert",
|
||||
"object_archived": "Objekt archiviert",
|
||||
"unarchive": "Wiederherstellen",
|
||||
"archived": "Archiviert",
|
||||
"show_archived": "Archivierte anzeigen",
|
||||
"hide_archived": "Archivierte ausblenden",
|
||||
"confirm_archive_object": "Dieses Objekt und seine Aufgaben archivieren? Verlauf bleibt erhalten, kann später wiederhergestellt werden.",
|
||||
"settings_archive": "Archiv & Aufbewahrung",
|
||||
"settings_archive_desc": "Erledigte einmalige Aufgaben archivieren statt löschen. Archivierte Einträge sind ausgeblendet und inaktiv, behalten aber Verlauf und Kosten.",
|
||||
"settings_archive_oneoff_days": "Erledigte einmalige Aufgaben automatisch archivieren nach (Tage, 0 = aus)",
|
||||
"settings_delete_archived_oneoff_days": "Archivierte einmalige Aufgaben automatisch löschen nach (Tage, 0 = nie)",
|
||||
"archive_object": "Objekt archivieren",
|
||||
"unarchive_object": "Objekt wiederherstellen",
|
||||
"documents": "Dokumente",
|
||||
"documents_empty": "Noch keine Dokumente.",
|
||||
"doc_upload": "Datei hochladen",
|
||||
"doc_uploading": "Wird hochgeladen…",
|
||||
"doc_add_link": "Link hinzufügen",
|
||||
"doc_link_url": "URL (https://…)",
|
||||
"doc_link_title": "Titel (optional)",
|
||||
"doc_open": "Öffnen",
|
||||
"doc_delete_confirm": "„{name}“ löschen?",
|
||||
"doc_too_large": "Datei ist zu groß (max. 25 MB).",
|
||||
"doc_upload_failed": "Upload fehlgeschlagen.",
|
||||
"completion_photo_optional": "Abschlussfoto (optional)",
|
||||
"add_photo": "Foto hinzufügen",
|
||||
"uploading": "Wird hochgeladen…",
|
||||
"remove": "Entfernen",
|
||||
"doc_deduped": "Bereits anderswo gespeichert — geteilt, kein zusätzlicher Speicher.",
|
||||
"doc_dup_in_object": "Diese Datei ist bereits an diesem Objekt angehängt.",
|
||||
"doc_link_invalid": "Nur http/https-Links sind erlaubt.",
|
||||
"doc_cat_manual": "Anleitung",
|
||||
"doc_cat_warranty": "Garantie",
|
||||
"doc_cat_invoice": "Rechnung",
|
||||
"doc_cat_spare_parts": "Ersatzteile",
|
||||
"doc_cat_photo": "Foto",
|
||||
"doc_cat_other": "Sonstiges",
|
||||
"doc_link_badge": "Link",
|
||||
"doc_storage_title": "Dokumentenspeicher",
|
||||
"doc_storage_saved": "Durch Deduplizierung gespart",
|
||||
"doc_storage_refresh": "Aktualisieren",
|
||||
"doc_download": "Herunterladen",
|
||||
"doc_close": "Schließen",
|
||||
"doc_camera": "Foto aufnehmen",
|
||||
"doc_drop_hint": "Dateien hier ablegen",
|
||||
"doc_task_none": "Keine Dokumente mit dieser Aufgabe verknüpft.",
|
||||
"doc_link_existing": "Dokument verknüpfen…",
|
||||
"doc_attach": "Verknüpfen",
|
||||
"doc_unlink": "Verknüpfung lösen",
|
||||
"doc_page": "Seite",
|
||||
"chart_range_7d": "7T",
|
||||
"chart_range_30d": "30T",
|
||||
"chart_range_90d": "90T",
|
||||
"chart_range_1y": "1J",
|
||||
"chart_since_service": "seit letzter Wartung",
|
||||
"chart_no_stats": "Keine Langzeit-Statistik für diese Entity — es werden nur Werte aus Wartungseinträgen angezeigt",
|
||||
"auto_complete_on_recovery": "Bei Sensor-Erholung automatisch abschließen",
|
||||
"auto_complete_on_recovery_help": "Verbucht eine Wartung (setzt „zuletzt durchgeführt“), sobald sich der Auslöser von selbst auflöst — z. B. Salz nachgefüllt, Filter getauscht.",
|
||||
"doc_search": "Dokumente durchsuchen…",
|
||||
"doc_search_none": "Keine passenden Dokumente",
|
||||
"link_device_optional": "Mit bestehendem Gerät verknüpfen (optional)",
|
||||
"parent_object_optional": "Übergeordnetes Objekt (optional)",
|
||||
"parent_none": "(Kein übergeordnetes Objekt)",
|
||||
"paused": "Pausiert",
|
||||
"pause_object": "Pausieren",
|
||||
"resume_object": "Fortsetzen",
|
||||
"pause_until_prompt": "Zeitpläne dieses Objekts einfrieren — nichts wird fällig und nichts benachrichtigt, bis es fortgesetzt wird. Optional ein Datum für die automatische Fortsetzung festlegen.",
|
||||
"pause_until_label": "Fortsetzen am (optional)",
|
||||
"object_paused": "Objekt pausiert",
|
||||
"object_resumed": "Objekt fortgesetzt — Zeitpläne neu gestartet",
|
||||
"object_paused_badge": "Pausiert",
|
||||
"paused_until_label": "bis",
|
||||
"replace_object": "Ersetzen…",
|
||||
"replace_object_prompt": "Dieses Objekt ausmustern und einen Nachfolger anlegen. Historie und Kosten bleiben am alten Objekt archiviert; Aufgaben und Dokumente wandern zum neuen, Zähler starten frisch.",
|
||||
"replace_name_label": "Name des Nachfolgers",
|
||||
"object_replaced": "Objekt ersetzt — Nachfolger angelegt",
|
||||
"reading_unit_label": "Ableseeinheit (z. B. kWh, m³)",
|
||||
"reading_unit_help": "Wird beim Erledigen dieser Aufgabe neben dem erfassten Wert angezeigt.",
|
||||
"reading_value_label": "Ablesewert",
|
||||
"reading_label": "Ablesung",
|
||||
"settings_templates_label": "Vorlagen-Galerie",
|
||||
"settings_templates_hint": "Vorlagen abwählen, die du nie brauchst — sie verschwinden aus den \"Aus Vorlage\"-Auswahlen (Panel und Config-Flow). Sonst ändert sich nichts; jederzeit wieder aktivierbar.",
|
||||
"worksheet": "Arbeitsblatt",
|
||||
"worksheet_scan_view": "Scannen, um die Aufgabe zu öffnen",
|
||||
"worksheet_scan_complete": "Scannen zum Erledigen",
|
||||
"worksheet_manual_excerpt": "Handbuch-Auszug",
|
||||
"worksheet_pages": "Seiten",
|
||||
"worksheet_printed": "Gedruckt",
|
||||
"worksheet_never": "Nie"
|
||||
}
|
||||
@@ -0,0 +1,696 @@
|
||||
{
|
||||
"maintenance": "Maintenance",
|
||||
"objects": "Objects",
|
||||
"tasks": "Tasks",
|
||||
"overdue": "Overdue",
|
||||
"due_soon": "Due Soon",
|
||||
"triggered": "Triggered",
|
||||
"trigger_replaced": "Trigger replaced",
|
||||
"ok": "OK",
|
||||
"all": "All",
|
||||
"new_object": "+ New Object",
|
||||
"templates_from": "From template",
|
||||
"templates_title": "Start from a template",
|
||||
"templates_task_count": "{n} tasks",
|
||||
"template_created": "Created from template",
|
||||
"onboard_hint": "Add your first object to start tracking maintenance.",
|
||||
"edit": "Edit",
|
||||
"duplicate": "Duplicate",
|
||||
"task_duplicated": "Task duplicated",
|
||||
"object_duplicated": "Object duplicated",
|
||||
"delete": "Delete",
|
||||
"add_task": "+ Add Task",
|
||||
"complete": "Complete",
|
||||
"completed": "Completed",
|
||||
"skip": "Skip",
|
||||
"skipped": "Skipped",
|
||||
"missed": "Missed",
|
||||
"reset": "Reset",
|
||||
"snooze": "Snooze",
|
||||
"snoozed": "Snoozed",
|
||||
"cancel": "Cancel",
|
||||
"bulk_select": "Select",
|
||||
"bulk_select_all": "Select all",
|
||||
"bulk_n_selected": "{n} selected",
|
||||
"bulk_completed": "{n} tasks completed",
|
||||
"bulk_archived": "{n} tasks archived",
|
||||
"completing": "Completing…",
|
||||
"interval": "Interval",
|
||||
"warning": "Warning",
|
||||
"last_performed": "Last performed",
|
||||
"next_due": "Next due",
|
||||
"days_until_due": "Days until due",
|
||||
"avg_duration": "Avg duration",
|
||||
"trigger": "Trigger",
|
||||
"trigger_type": "Trigger type",
|
||||
"threshold_above": "Upper limit",
|
||||
"threshold_below": "Lower limit",
|
||||
"threshold": "Threshold",
|
||||
"counter": "Counter",
|
||||
"state_change": "State change",
|
||||
"runtime": "Runtime",
|
||||
"runtime_hours": "Target runtime (hours)",
|
||||
"target_value": "Target value",
|
||||
"baseline": "Baseline",
|
||||
"target_changes": "Target changes",
|
||||
"for_minutes": "For (minutes)",
|
||||
"time_based": "Time-based",
|
||||
"sensor_based": "Sensor-based",
|
||||
"manual": "Manual",
|
||||
"one_time": "One-time",
|
||||
"weekdays": "Weekdays",
|
||||
"nth_weekday": "Nth weekday of month",
|
||||
"day_of_month": "Day of month",
|
||||
"recurrence_on_days": "Repeat on",
|
||||
"recurrence_occurrence": "Occurrence",
|
||||
"recurrence_weekday": "Weekday",
|
||||
"recurrence_day": "Day of month (1–31)",
|
||||
"recurrence_last_day": "Last day of the month",
|
||||
"recurrence_business_day": "Business days only (roll back from weekend)",
|
||||
"recurrence_offset": "Offset (days, ±)",
|
||||
"recurrence_offset_help": "Shift the date by ±N days, e.g. -2 = two days before.",
|
||||
"last_day_month": "Last day of month",
|
||||
"last_business_day_month": "Last business day",
|
||||
"ord_1": "1st",
|
||||
"ord_2": "2nd",
|
||||
"ord_3": "3rd",
|
||||
"ord_4": "4th",
|
||||
"ord_5": "5th",
|
||||
"ord_last": "Last",
|
||||
"day_word": "Day",
|
||||
"interval_value": "Interval",
|
||||
"interval_unit": "Unit",
|
||||
"unit_days": "Days",
|
||||
"unit_weeks": "Weeks",
|
||||
"unit_months": "Months",
|
||||
"unit_years": "Years",
|
||||
"due_date": "Due date",
|
||||
"cleaning": "Cleaning",
|
||||
"inspection": "Inspection",
|
||||
"replacement": "Replacement",
|
||||
"calibration": "Calibration",
|
||||
"service": "Service",
|
||||
"reading": "Reading",
|
||||
"custom": "Custom",
|
||||
"history": "History",
|
||||
"cost": "Cost",
|
||||
"report_button": "Report",
|
||||
"report_title": "Maintenance report",
|
||||
"report_generated": "Generated",
|
||||
"report_times_done": "Done",
|
||||
"report_total_cost": "Total cost",
|
||||
"report_every": "every {n} {unit}",
|
||||
"report_notes": "Notes",
|
||||
"report_col_type": "Type",
|
||||
"report_col_status": "Status",
|
||||
"report_col_schedule": "Schedule",
|
||||
"duration": "Duration",
|
||||
"both": "Both",
|
||||
"trigger_val": "Trigger value",
|
||||
"complete_title": "Complete: ",
|
||||
"checklist": "Checklist",
|
||||
"checklist_steps_optional": "Checklist steps (optional)",
|
||||
"checklist_placeholder": "Clean filter\nReplace seal\nTest pressure",
|
||||
"checklist_help": "One step per line. Max 100 items.",
|
||||
"err_too_long": "{field}: too long (max {n} characters)",
|
||||
"err_too_short": "{field}: too short (min {n} characters)",
|
||||
"err_value_too_high": "{field}: too large (max {n})",
|
||||
"err_value_too_low": "{field}: too small (min {n})",
|
||||
"err_required": "{field}: required",
|
||||
"err_wrong_type": "{field}: wrong type (expected: {type})",
|
||||
"err_invalid_choice": "{field}: not an allowed value",
|
||||
"err_invalid_value": "{field}: invalid value",
|
||||
"feat_schedule_time": "Time-of-day scheduling",
|
||||
"feat_schedule_time_desc": "Tasks become overdue at a specific time of day instead of midnight.",
|
||||
"schedule_time_optional": "Due at time (optional, HH:MM)",
|
||||
"schedule_time_help": "Empty = midnight (default). HA timezone.",
|
||||
"at_time": "at",
|
||||
"notes_optional": "Notes (optional)",
|
||||
"cost_optional": "Cost (optional)",
|
||||
"duration_minutes": "Duration in minutes (optional)",
|
||||
"days": "days",
|
||||
"day": "day",
|
||||
"today": "Today",
|
||||
"d_overdue": "d overdue",
|
||||
"no_tasks": "No maintenance tasks yet. Create an object to get started.",
|
||||
"no_tasks_short": "No tasks",
|
||||
"no_history": "No history entries yet.",
|
||||
"show_all": "Show all",
|
||||
"cost_duration_chart": "Cost & Duration",
|
||||
"installed": "Installed",
|
||||
"confirm_delete_object": "Delete this object and all its tasks?",
|
||||
"confirm_delete_task": "Delete this task?",
|
||||
"min": "Min",
|
||||
"max": "Max",
|
||||
"save": "Save",
|
||||
"saving": "Saving…",
|
||||
"edit_task": "Edit Task",
|
||||
"new_task": "New Maintenance Task",
|
||||
"task_name": "Task name",
|
||||
"maintenance_type": "Maintenance type",
|
||||
"priority": "Priority",
|
||||
"labels": "Labels",
|
||||
"labels_placeholder": "e.g. safety, seasonal, tenant-visible",
|
||||
"labels_help": "Comma-separated tags for filtering and reporting.",
|
||||
"priority_low": "Low",
|
||||
"priority_normal": "Normal",
|
||||
"priority_high": "High",
|
||||
"schedule_type": "Schedule type",
|
||||
"interval_days": "Interval (days)",
|
||||
"warning_days": "Warning days",
|
||||
"earliest_completion_days": "Earliest completion (days before due)",
|
||||
"earliest_completion_days_help": "Leave empty to allow completing any time. 0 = only on/after the due date.",
|
||||
"last_performed_optional": "Last performed (optional)",
|
||||
"interval_anchor": "Interval anchor",
|
||||
"anchor_completion": "From completion date",
|
||||
"anchor_planned": "From planned date (no drift)",
|
||||
"edit_object": "Edit Object",
|
||||
"name": "Name",
|
||||
"manufacturer_optional": "Manufacturer (optional)",
|
||||
"model_optional": "Model (optional)",
|
||||
"serial_number_optional": "Serial number (optional)",
|
||||
"serial_number_label": "S/N",
|
||||
"documentation_url_label": "Manual",
|
||||
"object_notes_label": "Notes",
|
||||
"sort_due_date": "Due date",
|
||||
"sort_object": "Object name",
|
||||
"sort_type": "Type",
|
||||
"sort_task_name": "Task name",
|
||||
"all_objects": "All objects",
|
||||
"tasks_lower": "tasks",
|
||||
"no_tasks_yet": "No tasks yet",
|
||||
"add_first_task": "Add first task",
|
||||
"trigger_configuration": "Trigger Configuration",
|
||||
"entity_id": "Entity ID",
|
||||
"comma_separated": "comma-separated",
|
||||
"entity_logic": "Entity logic",
|
||||
"entity_logic_any": "Any entity triggers",
|
||||
"entity_logic_all": "All entities must trigger",
|
||||
"entities": "entities",
|
||||
"attribute_optional": "Attribute (optional, blank = state)",
|
||||
"use_entity_state": "Use entity state (no attribute)",
|
||||
"trigger_above": "Trigger above",
|
||||
"trigger_below": "Trigger below",
|
||||
"for_at_least_minutes": "For at least (minutes)",
|
||||
"safety_interval_days": "Safety interval (days, optional)",
|
||||
"safety_interval": "Safety interval (optional)",
|
||||
"delta_mode": "Delta mode",
|
||||
"from_state_optional": "From state (optional)",
|
||||
"to_state_optional": "To state (optional)",
|
||||
"documentation_url_optional": "Documentation URL (optional)",
|
||||
"object_notes_optional": "Notes (optional)",
|
||||
"nfc_tag_id_optional": "NFC Tag ID (optional)",
|
||||
"nfc_tags_empty_help": "No NFC tags registered in Home Assistant yet.",
|
||||
"nfc_tags_open_settings": "Open Tags settings",
|
||||
"nfc_tags_refresh": "Refresh",
|
||||
"environmental_entity_optional": "Environmental sensor (optional)",
|
||||
"environmental_entity_helper": "e.g. sensor.outdoor_temperature — adjusts the interval based on environmental conditions",
|
||||
"environmental_attribute_optional": "Environmental attribute (optional)",
|
||||
"nfc_tag_id": "NFC Tag ID",
|
||||
"nfc_linked": "NFC tag linked",
|
||||
"nfc_link_hint": "Click to link NFC tag",
|
||||
"responsible_user": "Responsible User",
|
||||
"shared_with": "Shared with (rotation)",
|
||||
"shared_with_help": "Pick multiple people to share this task; the responsible person rotates on each completion.",
|
||||
"rotation_strategy": "Rotation",
|
||||
"rotation_none": "No rotation",
|
||||
"rotation_round_robin": "Round-robin",
|
||||
"rotation_least_completed": "Least completed",
|
||||
"rotation_random": "Random",
|
||||
"no_user_assigned": "(No user assigned)",
|
||||
"all_users": "All Users",
|
||||
"my_tasks": "My Tasks",
|
||||
"tab_calendar": "Calendar",
|
||||
"cal_no_events": "No maintenance",
|
||||
"cal_window_7": "7 days",
|
||||
"cal_window_14": "14 days",
|
||||
"cal_window_30": "30 days",
|
||||
"cal_window_365": "1 year",
|
||||
"cal_every_n_days": "every {n} days",
|
||||
"cal_source_time": "Time-based",
|
||||
"cal_source_time_adaptive": "Time-based (adaptive)",
|
||||
"cal_source_sensor": "Sensor-based",
|
||||
"cal_predicted": "predicted",
|
||||
"cal_confidence_high": "high confidence",
|
||||
"cal_confidence_medium": "medium confidence",
|
||||
"cal_confidence_low": "low confidence",
|
||||
"budget_monthly": "Monthly budget",
|
||||
"budget_yearly": "Yearly budget",
|
||||
"groups": "Groups",
|
||||
"new_group": "New group",
|
||||
"edit_group": "Edit group",
|
||||
"no_groups": "No groups yet",
|
||||
"delete_group": "Delete group",
|
||||
"delete_group_confirm": "Delete group '{name}'?",
|
||||
"group_select_tasks": "Select tasks",
|
||||
"group_name_required": "Name is required",
|
||||
"description_optional": "Description (optional)",
|
||||
"selected": "Selected",
|
||||
"loading_chart": "Loading chart data...",
|
||||
"hide_outliers": "Hide outliers (sensor glitches)",
|
||||
"was_maintenance_needed": "Was this maintenance needed?",
|
||||
"feedback_needed": "Needed",
|
||||
"feedback_not_needed": "Not needed",
|
||||
"feedback_not_sure": "Not sure",
|
||||
"suggested_interval": "Suggested interval",
|
||||
"apply_suggestion": "Apply",
|
||||
"reanalyze": "Re-analyze",
|
||||
"reanalyze_result": "New analysis",
|
||||
"reanalyze_insufficient_data": "Not enough data to produce a recommendation",
|
||||
"data_points": "data points",
|
||||
"dismiss_suggestion": "Dismiss",
|
||||
"confidence_low": "Low",
|
||||
"confidence_medium": "Medium",
|
||||
"confidence_high": "High",
|
||||
"recommended": "recommended",
|
||||
"seasonal_awareness": "Seasonal Awareness",
|
||||
"edit_seasonal_overrides": "Edit seasonal factors",
|
||||
"seasonal_overrides_title": "Seasonal factors (override)",
|
||||
"seasonal_overrides_hint": "Factor per month (0.1–5.0). Empty = learned automatically.",
|
||||
"seasonal_override_invalid": "Invalid value",
|
||||
"seasonal_override_range": "Factor must be between 0.1 and 5.0",
|
||||
"clear_all": "Clear all",
|
||||
"seasonal_chart_title": "Seasonal Factors",
|
||||
"seasonal_learned": "Learned",
|
||||
"seasonal_manual": "Manual",
|
||||
"month_jan": "Jan",
|
||||
"month_feb": "Feb",
|
||||
"month_mar": "Mar",
|
||||
"month_apr": "Apr",
|
||||
"month_may": "May",
|
||||
"month_jun": "Jun",
|
||||
"month_jul": "Jul",
|
||||
"month_aug": "Aug",
|
||||
"month_sep": "Sep",
|
||||
"month_oct": "Oct",
|
||||
"month_nov": "Nov",
|
||||
"month_dec": "Dec",
|
||||
"sensor_prediction": "Sensor Prediction",
|
||||
"degradation_trend": "Trend",
|
||||
"trend_rising": "Rising",
|
||||
"trend_falling": "Falling",
|
||||
"trend_stable": "Stable",
|
||||
"trend_insufficient_data": "Insufficient data",
|
||||
"days_until_threshold": "Days until threshold",
|
||||
"threshold_exceeded": "Threshold exceeded",
|
||||
"environmental_adjustment": "Environmental factor",
|
||||
"sensor_prediction_urgency": "Sensor predicts threshold in ~{days} days",
|
||||
"day_short": "day",
|
||||
"weibull_reliability_curve": "Reliability Curve",
|
||||
"weibull_failure_probability": "Failure Probability",
|
||||
"weibull_r_squared": "Fit R²",
|
||||
"beta_early_failures": "Early Failures",
|
||||
"beta_random_failures": "Random Failures",
|
||||
"beta_wear_out": "Wear-out",
|
||||
"beta_highly_predictable": "Highly Predictable",
|
||||
"confidence_interval": "Confidence Interval",
|
||||
"confidence_conservative": "Conservative",
|
||||
"confidence_aggressive": "Optimistic",
|
||||
"current_interval_marker": "Current interval",
|
||||
"recommended_marker": "Recommended",
|
||||
"characteristic_life": "Characteristic life",
|
||||
"chart_mini_sparkline": "Trend sparkline",
|
||||
"chart_history": "Cost and duration history",
|
||||
"chart_seasonal": "Seasonal factors, 12 months",
|
||||
"chart_weibull": "Weibull reliability curve",
|
||||
"chart_sparkline": "Sensor trigger value chart",
|
||||
"days_progress": "Days progress",
|
||||
"qr_code": "QR Code",
|
||||
"qr_generating": "Generating QR code…",
|
||||
"qr_error": "Failed to generate QR code.",
|
||||
"qr_error_no_url": "No HA URL configured. Please set an external or internal URL in Settings → System → Network.",
|
||||
"save_error": "Failed to save. Please try again.",
|
||||
"qr_print": "Print",
|
||||
"qr_download": "Download SVG",
|
||||
"qr_action": "Action on scan",
|
||||
"qr_action_view": "View maintenance info",
|
||||
"qr_action_complete": "Mark maintenance as complete",
|
||||
"qr_url_mode": "Link type",
|
||||
"qr_mode_companion": "Companion App",
|
||||
"qr_mode_local": "Local (mDNS)",
|
||||
"qr_mode_server": "Server URL",
|
||||
"overview": "Overview",
|
||||
"analysis": "Analysis",
|
||||
"recent_activities": "Recent Activities",
|
||||
"search_notes": "Search notes",
|
||||
"avg_cost": "Avg Cost",
|
||||
"no_advanced_features": "No advanced features enabled",
|
||||
"no_advanced_features_hint": "Enable “Adaptive Intervals” or “Seasonal Patterns” in the integration settings to see analysis data here.",
|
||||
"analysis_not_enough_data": "Not enough data for analysis yet.",
|
||||
"analysis_not_enough_data_hint": "Weibull analysis requires at least 5 completed maintenances; seasonal patterns become visible after 6+ data points per month.",
|
||||
"analysis_manual_task_hint": "Manual tasks without an interval do not generate analysis data.",
|
||||
"completions": "completions",
|
||||
"current": "Current",
|
||||
"shorter": "Shorter",
|
||||
"longer": "Longer",
|
||||
"normal": "Normal",
|
||||
"disabled": "Disabled",
|
||||
"compound_logic": "Compound logic",
|
||||
"compound": "Compound (multiple conditions)",
|
||||
"compound_logic_and": "AND — all conditions must trigger",
|
||||
"compound_logic_or": "OR — any condition triggers",
|
||||
"compound_help": "Combine several sensor conditions into one trigger.",
|
||||
"compound_no_conditions": "No conditions yet — add at least one.",
|
||||
"compound_add_condition": "Add condition",
|
||||
"compound_condition": "Condition",
|
||||
"compound_remove_condition": "Remove condition",
|
||||
"card_title": "Title",
|
||||
"card_show_header": "Show header with statistics",
|
||||
"card_show_actions": "Show action buttons",
|
||||
"card_compact": "Compact mode",
|
||||
"card_max_items": "Max items (0 = all)",
|
||||
"card_filter_status": "Filter by status",
|
||||
"card_filter_status_help": "Empty = show all statuses.",
|
||||
"card_filter_objects": "Filter by objects",
|
||||
"card_filter_objects_help": "Empty = show all objects.",
|
||||
"card_filter_entities": "Filter by entities (entity_ids)",
|
||||
"card_filter_entities_help": "Pick sensor / binary_sensor entities from this integration. Empty = all.",
|
||||
"card_loading_objects": "Loading objects…",
|
||||
"card_load_error": "Could not load objects — check the WebSocket connection.",
|
||||
"card_no_tasks_title": "No maintenance tasks yet",
|
||||
"card_no_tasks_cta": "→ Create one in the Maintenance panel",
|
||||
"no_objects": "No objects yet.",
|
||||
"action_error": "Action failed. Please try again.",
|
||||
"area_id_optional": "Area (optional)",
|
||||
"installation_date_optional": "Installation date (optional)",
|
||||
"warranty_expiry_optional": "Warranty expiry (optional)",
|
||||
"warranty": "Warranty",
|
||||
"warranty_valid_until": "valid until {date}",
|
||||
"warranty_expires_in": "expires in {days} days",
|
||||
"warranty_expired": "expired",
|
||||
"cal_past_windows": "Past windows",
|
||||
"cal_forward_windows": "Forward windows",
|
||||
"history_edit_title": "Edit history entry",
|
||||
"history_edit_timestamp": "Timestamp",
|
||||
"manufacturer": "Manufacturer",
|
||||
"model": "Model",
|
||||
"area": "Area",
|
||||
"actions": "Actions",
|
||||
"view_mode_label": "View",
|
||||
"view_cards": "Card view",
|
||||
"view_table": "Table view",
|
||||
"objects_table_columns_label": "Objects table columns",
|
||||
"objects_table_columns_hint": "Choose which columns appear in the objects table view.",
|
||||
"custom_icon_optional": "Icon (optional, e.g. mdi:wrench)",
|
||||
"task_enabled": "Task enabled",
|
||||
"skip_reason_prompt": "Skip this task?",
|
||||
"reason_optional": "Reason (optional)",
|
||||
"reset_date_prompt": "Mark task as performed?",
|
||||
"reset_date_optional": "Last performed date (optional, defaults to today)",
|
||||
"notes_label": "Notes",
|
||||
"documentation_label": "Documentation",
|
||||
"no_nfc_tag": "— No tag —",
|
||||
"dashboard": "Dashboard",
|
||||
"tab_today": "Today",
|
||||
"palette_placeholder": "Search objects and tasks…",
|
||||
"palette_no_results": "No matches",
|
||||
"palette_hint": "↑↓ to navigate · Enter to open · Esc to close",
|
||||
"today_all_caught_up": "All caught up! Nothing due this week.",
|
||||
"today_overdue": "Overdue",
|
||||
"today_due_today": "Due today",
|
||||
"today_this_week": "This week",
|
||||
"settings": "Settings",
|
||||
"settings_features": "Advanced Features",
|
||||
"settings_features_desc": "Enable or disable advanced features. Disabling hides them from the UI but does not delete data.",
|
||||
"feat_adaptive": "Adaptive Scheduling",
|
||||
"feat_adaptive_desc": "Learn optimal intervals from maintenance history",
|
||||
"feat_predictions": "Sensor Predictions",
|
||||
"feat_predictions_desc": "Predict trigger dates from sensor degradation",
|
||||
"feat_seasonal": "Seasonal Adjustments",
|
||||
"feat_seasonal_desc": "Adjust intervals based on seasonal patterns",
|
||||
"feat_environmental": "Environmental Correlation",
|
||||
"feat_environmental_desc": "Correlate intervals with temperature/humidity",
|
||||
"feat_budget": "Budget Tracking",
|
||||
"feat_budget_desc": "Track monthly and yearly maintenance spending",
|
||||
"feat_groups": "Task Groups",
|
||||
"feat_groups_desc": "Organize tasks into logical groups",
|
||||
"feat_checklists": "Checklists",
|
||||
"feat_checklists_desc": "Multi-step procedures for task completion",
|
||||
"settings_general": "General",
|
||||
"settings_default_warning": "Default warning days",
|
||||
"settings_panel_enabled": "Sidebar panel",
|
||||
"settings_panel_title": "Sidebar panel title",
|
||||
"settings_notifications": "Notifications",
|
||||
"settings_notify_service": "Notification service",
|
||||
"test_notification": "Test notification",
|
||||
"send_test": "Send test",
|
||||
"testing": "Sending…",
|
||||
"test_notification_success": "Test notification sent",
|
||||
"test_notification_failed": "Test notification failed",
|
||||
"settings_notify_due_soon": "Notify when due soon",
|
||||
"settings_notify_overdue": "Notify when overdue",
|
||||
"settings_notify_triggered": "Notify when triggered",
|
||||
"settings_interval_hours": "Repeat interval (hours, 0 = once)",
|
||||
"settings_quiet_hours": "Quiet hours",
|
||||
"settings_quiet_start": "Start",
|
||||
"settings_quiet_end": "End",
|
||||
"settings_max_per_day": "Max notifications per day (0 = unlimited)",
|
||||
"settings_bundling": "Bundle notifications",
|
||||
"settings_bundle_threshold": "Bundle threshold",
|
||||
"settings_reminder_leads": "Extra reminders (days before due)",
|
||||
"settings_reminder_leads_hint": "Comma-separated lead times, e.g. 14, 3, 0 — one extra reminder fires on each matching day. Empty = off.",
|
||||
"settings_actions": "Mobile Action Buttons",
|
||||
"settings_action_complete": "Show 'Complete' button",
|
||||
"settings_action_skip": "Show 'Skip' button",
|
||||
"settings_action_snooze": "Show 'Snooze' button",
|
||||
"settings_weekly_digest": "Weekly digest",
|
||||
"settings_weekly_digest_hint": "A single summary notification on Monday morning when tasks are due.",
|
||||
"settings_warranty_reminder": "Warranty expiry reminder",
|
||||
"settings_warranty_reminder_days": "Days before expiry",
|
||||
"settings_warranty_reminder_hint": "Notify once when an object's warranty is this many days from expiring.",
|
||||
"settings_snooze_hours": "Snooze duration (hours)",
|
||||
"settings_budget": "Budget",
|
||||
"settings_currency": "Currency",
|
||||
"settings_budget_monthly": "Monthly budget",
|
||||
"settings_budget_yearly": "Yearly budget",
|
||||
"settings_budget_alerts": "Budget alerts",
|
||||
"settings_budget_threshold": "Alert threshold (%)",
|
||||
"settings_import_export": "Import / Export",
|
||||
"settings_export_json": "Export JSON",
|
||||
"settings_export_yaml": "Export YAML",
|
||||
"settings_export_csv": "Export CSV",
|
||||
"settings_import_csv": "Import CSV",
|
||||
"settings_import_placeholder": "Paste JSON or CSV content here…",
|
||||
"settings_import_btn": "Import",
|
||||
"settings_import_success": "{count} objects imported successfully.",
|
||||
"settings_export_success": "Export downloaded.",
|
||||
"settings_saved": "Setting saved.",
|
||||
"settings_include_history": "Include history",
|
||||
"sort_alphabetical": "Alphabetical",
|
||||
"sort_due_soonest": "Due soonest",
|
||||
"sort_task_count": "Task count",
|
||||
"sort_area": "Area",
|
||||
"sort_assigned_user": "Assigned user",
|
||||
"sort_group": "Group",
|
||||
"groupby_none": "No grouping",
|
||||
"groupby_area": "By area",
|
||||
"groupby_group": "By group",
|
||||
"groupby_user": "By user",
|
||||
"filter_label": "Filter",
|
||||
"user_label": "User",
|
||||
"sort_label": "Sort",
|
||||
"group_by_label": "Group by",
|
||||
"state_value_help": "Use the HA state value (usually lowercase, e.g. \"on\"/\"off\"). Case is normalised on save.",
|
||||
"target_changes_help": "Number of matching transitions before the trigger fires (default: 1).",
|
||||
"qr_print_title": "Print QR codes",
|
||||
"qr_print_desc": "Generate a printable page of QR codes to cut out and stick on your equipment.",
|
||||
"qr_print_load": "Load objects",
|
||||
"qr_print_filter": "Filter",
|
||||
"qr_print_objects": "Objects",
|
||||
"qr_print_actions": "Actions",
|
||||
"qr_print_url_mode": "Link type",
|
||||
"qr_print_estimate": "Estimated QR codes",
|
||||
"qr_print_over_limit": "cap is 200, narrow the filter",
|
||||
"qr_print_generate": "Generate QR codes",
|
||||
"qr_print_generating": "Generating…",
|
||||
"qr_print_ready": "QR codes ready",
|
||||
"qr_print_print_button": "Print",
|
||||
"qr_print_empty": "Nothing to generate",
|
||||
"qr_action_skip": "Skip",
|
||||
"vacation_title": "Vacation mode",
|
||||
"vacation_active": "active",
|
||||
"vacation_ended": "ended",
|
||||
"vacation_desc": "Plan a vacation: notifications are paused during the period plus a buffer of days. You can opt specific tasks back in.",
|
||||
"vacation_enable": "Enable vacation mode",
|
||||
"vacation_start": "Start",
|
||||
"vacation_end": "End",
|
||||
"vacation_buffer": "Buffer (days)",
|
||||
"vacation_exempt_title": "Notify anyway during vacation",
|
||||
"vacation_exempt_desc": "Pick tasks that should still notify during vacation (e.g. critical pool chemistry).",
|
||||
"vacation_load_tasks": "Load tasks",
|
||||
"vacation_preview_btn": "Show preview",
|
||||
"vacation_preview_affected": "tasks affected",
|
||||
"vacation_event_due_soon": "becomes due soon",
|
||||
"vacation_event_overdue": "becomes overdue",
|
||||
"vacation_event_triggered_est": "sensor trigger possible",
|
||||
"vacation_sensor_based": "(sensor-based)",
|
||||
"vacation_action_notify": "Notify anyway",
|
||||
"vacation_action_unsilence": "Silence again",
|
||||
"vacation_marked_complete": "Marked complete",
|
||||
"vacation_marked_skip": "Skipped",
|
||||
"vacation_end_now": "End vacation now",
|
||||
"add": "Add",
|
||||
"show_stats": "Show stats + graphs",
|
||||
"hide_stats": "Hide stats",
|
||||
"adaptive_no_data": "Not enough completion history yet for adaptive analysis. Complete this task a few more times to unlock interval recommendations and reliability charts.",
|
||||
"suggestion_applied": "Suggested interval applied",
|
||||
"vacation_mode": "Vacation mode",
|
||||
"vacation_status_active": "Active now",
|
||||
"vacation_status_scheduled": "Scheduled",
|
||||
"vacation_status_inactive": "Inactive",
|
||||
"vacation_end_now_confirm": "End vacation immediately?",
|
||||
"vacation_exempt_count": "exempt",
|
||||
"vacation_advanced": "Advanced…",
|
||||
"vacation_open_panel": "Open in panel",
|
||||
"enable": "Enable",
|
||||
"saved": "Saved",
|
||||
"budget_monthly_set": "Set monthly",
|
||||
"budget_yearly_set": "Set yearly",
|
||||
"budget_advanced": "Currency, alerts…",
|
||||
"budget_open_panel": "Open in panel",
|
||||
"groups_empty": "No groups yet.",
|
||||
"group_new_placeholder": "Add group…",
|
||||
"group_delete_confirm": "Delete group \"{name}\"?",
|
||||
"groups_manage_tasks": "Manage task assignments…",
|
||||
"groups_open_panel": "Open in panel",
|
||||
"unassigned": "Unassigned",
|
||||
"no_area": "No area",
|
||||
"has_overdue": "Has overdue tasks",
|
||||
"object": "Object",
|
||||
"settings_panel_access": "Panel access",
|
||||
"settings_panel_access_desc": "Admins always have full access. To delegate create, edit and delete to specific non-admins, switch this on and pick them below — everyone else sees only Complete and Skip.",
|
||||
"settings_operator_write": "Allow selected users to create, edit & delete",
|
||||
"settings_operator_write_desc": "Off: only admins can change content. On: the selected users below get full access too.",
|
||||
"no_non_admin_users": "No non-admin users found. Add some in Settings → People.",
|
||||
"owner_label": "Owner",
|
||||
"feat_completion_actions": "Completion actions",
|
||||
"feat_completion_actions_desc": "Per-task HA action on complete + quick-complete QR with pre-set values.",
|
||||
"on_complete_action_title": "On complete: trigger HA action (optional)",
|
||||
"on_complete_action_desc": "Calls an HA service when the task is completed — e.g. reset a counter on the device.",
|
||||
"on_complete_action_service": "Service",
|
||||
"on_complete_action_target": "Target entity",
|
||||
"on_complete_action_target_hint": "Note: the entity domain must match the service — e.g. 'button.press' only works on button.*, 'counter.increment' only on counter.*, 'input_button.press' only on input_button.* etc. On a mismatch the action will silently fail (HA logs 'Referenced entities ... missing or not currently available').",
|
||||
"on_complete_action_data": "Data (JSON, optional)",
|
||||
"on_complete_action_test": "Validate configuration",
|
||||
"on_complete_action_test_success": "✓ Configuration valid (action will fire only on task completion)",
|
||||
"on_complete_action_test_failed": "Failed",
|
||||
"quick_complete_defaults_title": "Quick-complete defaults (for QR scans, optional)",
|
||||
"quick_complete_defaults_desc": "Pre-set values for quick-complete QR scans. Without these, the QR opens the complete dialog.",
|
||||
"quick_complete_defaults_notes": "Notes",
|
||||
"quick_complete_defaults_cost": "Cost",
|
||||
"quick_complete_defaults_duration": "Duration (minutes)",
|
||||
"quick_complete_defaults_feedback_none": "No feedback",
|
||||
"quick_complete_defaults_feedback_needed": "Was needed",
|
||||
"quick_complete_defaults_feedback_not_needed": "Not needed",
|
||||
"quick_complete_success": "Quickly marked complete",
|
||||
"show_all_objects": "Show all objects",
|
||||
"show_all_tasks": "Clear filter — show all tasks",
|
||||
"filter_to_overdue": "Filter task list to overdue only",
|
||||
"filter_to_due_soon": "Filter task list to due-soon only",
|
||||
"filter_to_triggered": "Filter task list to triggered only",
|
||||
"open_task": "Open task",
|
||||
"show_details": "Show history + stats",
|
||||
"hide_details": "Hide details",
|
||||
"history_empty": "No history yet.",
|
||||
"history_edit_button": "Edit entry",
|
||||
"total_cost": "Total cost",
|
||||
"times_performed": "Performed",
|
||||
"older_entries": "older",
|
||||
"open_in_panel": "Open in Maintenance panel",
|
||||
"skip_reason": "Skip reason (optional)",
|
||||
"reset_to_date": "Reset last_performed to",
|
||||
"delete_task_confirm": "Delete this task and its history?",
|
||||
"delete_object_confirm": "Delete this object and all its tasks?",
|
||||
"loading": "Loading…",
|
||||
"archive": "Archive",
|
||||
"undo": "Undo",
|
||||
"task_archived": "Task archived",
|
||||
"object_archived": "Object archived",
|
||||
"unarchive": "Unarchive",
|
||||
"archived": "Archived",
|
||||
"show_archived": "Show archived",
|
||||
"hide_archived": "Hide archived",
|
||||
"confirm_archive_object": "Archive this object and its tasks? They keep their history and can be unarchived later.",
|
||||
"settings_archive": "Archive & Retention",
|
||||
"settings_archive_desc": "Retire completed one-off tasks without deleting them. Archived items are hidden and inert but keep their history and cost.",
|
||||
"settings_archive_oneoff_days": "Auto-archive completed one-off tasks after (days, 0 = off)",
|
||||
"settings_delete_archived_oneoff_days": "Auto-delete archived one-off tasks after (days, 0 = never)",
|
||||
"archive_object": "Archive object",
|
||||
"unarchive_object": "Unarchive object",
|
||||
"documents": "Documents",
|
||||
"documents_empty": "No documents yet.",
|
||||
"doc_upload": "Upload file",
|
||||
"doc_uploading": "Uploading…",
|
||||
"doc_add_link": "Add link",
|
||||
"doc_link_url": "URL (https://…)",
|
||||
"doc_link_title": "Title (optional)",
|
||||
"doc_open": "Open",
|
||||
"doc_delete_confirm": "Delete \"{name}\"?",
|
||||
"doc_too_large": "File is too large (max 25 MB).",
|
||||
"doc_upload_failed": "Upload failed.",
|
||||
"completion_photo_optional": "Completion photo (optional)",
|
||||
"add_photo": "Add photo",
|
||||
"uploading": "Uploading…",
|
||||
"remove": "Remove",
|
||||
"doc_deduped": "Already stored elsewhere — shared, no extra space used.",
|
||||
"doc_dup_in_object": "This file is already attached to this object.",
|
||||
"doc_link_invalid": "Only http/https links are allowed.",
|
||||
"doc_cat_manual": "Manual",
|
||||
"doc_cat_warranty": "Warranty",
|
||||
"doc_cat_invoice": "Invoice",
|
||||
"doc_cat_spare_parts": "Spare parts",
|
||||
"doc_cat_photo": "Photo",
|
||||
"doc_cat_other": "Other",
|
||||
"doc_link_badge": "Link",
|
||||
"doc_storage_title": "Document storage",
|
||||
"doc_storage_saved": "Saved via deduplication",
|
||||
"doc_storage_refresh": "Refresh",
|
||||
"doc_download": "Download",
|
||||
"doc_close": "Close",
|
||||
"doc_camera": "Take photo",
|
||||
"doc_drop_hint": "Drop files here",
|
||||
"doc_task_none": "No documents linked to this task.",
|
||||
"doc_link_existing": "Link a document…",
|
||||
"doc_attach": "Link",
|
||||
"doc_unlink": "Unlink",
|
||||
"doc_page": "Page",
|
||||
"chart_range_7d": "7d",
|
||||
"chart_range_30d": "30d",
|
||||
"chart_range_90d": "90d",
|
||||
"chart_range_1y": "1y",
|
||||
"chart_since_service": "since last service",
|
||||
"chart_no_stats": "No long-term statistics for this entity — showing maintenance-event values only",
|
||||
"auto_complete_on_recovery": "Auto-complete when the sensor recovers",
|
||||
"auto_complete_on_recovery_help": "Records a completion (sets last performed) when the trigger clears itself — e.g. salt refilled, filter replaced.",
|
||||
"doc_search": "Search documents…",
|
||||
"doc_search_none": "No matching documents",
|
||||
"link_device_optional": "Link to existing device (optional)",
|
||||
"parent_object_optional": "Parent object (optional)",
|
||||
"parent_none": "(No parent)",
|
||||
"paused": "Paused",
|
||||
"pause_object": "Pause",
|
||||
"resume_object": "Resume",
|
||||
"pause_until_prompt": "Freeze this object's schedules — nothing becomes due and nothing notifies until it is resumed. Optionally set an auto-resume date.",
|
||||
"pause_until_label": "Resume on (optional)",
|
||||
"object_paused": "Object paused",
|
||||
"object_resumed": "Object resumed — schedules restarted",
|
||||
"object_paused_badge": "Paused",
|
||||
"paused_until_label": "until",
|
||||
"replace_object": "Replace…",
|
||||
"replace_object_prompt": "Retire this object and create a successor. History and costs stay archived on the old one; tasks and documents carry over to the new one, counters start fresh.",
|
||||
"replace_name_label": "Successor name",
|
||||
"object_replaced": "Object replaced — successor created",
|
||||
"reading_unit_label": "Reading unit (e.g. kWh, m³)",
|
||||
"reading_unit_help": "Shown next to the recorded value when completing this task.",
|
||||
"reading_value_label": "Reading value",
|
||||
"reading_label": "Reading",
|
||||
"settings_templates_label": "Template gallery",
|
||||
"settings_templates_hint": "Untick templates you'll never need — they disappear from the \"From template\" pickers (panel and config flow). Nothing else changes; you can re-enable them any time.",
|
||||
"worksheet": "Work sheet",
|
||||
"worksheet_scan_view": "Scan to open the task",
|
||||
"worksheet_scan_complete": "Scan to complete",
|
||||
"worksheet_manual_excerpt": "Manual excerpt",
|
||||
"worksheet_pages": "pages",
|
||||
"worksheet_printed": "Printed",
|
||||
"worksheet_never": "Never"
|
||||
}
|
||||
@@ -0,0 +1,696 @@
|
||||
{
|
||||
"maintenance": "Mantenimiento",
|
||||
"objects": "Objetos",
|
||||
"tasks": "Tareas",
|
||||
"overdue": "Vencida",
|
||||
"due_soon": "Próxima",
|
||||
"triggered": "Activada",
|
||||
"ok": "OK",
|
||||
"all": "Todos",
|
||||
"new_object": "+ Nuevo objeto",
|
||||
"templates_from": "Desde plantilla",
|
||||
"templates_title": "Empezar desde una plantilla",
|
||||
"templates_task_count": "{n} tareas",
|
||||
"template_created": "Creado desde plantilla",
|
||||
"onboard_hint": "Añade tu primer objeto para empezar a seguir el mantenimiento.",
|
||||
"edit": "Editar",
|
||||
"duplicate": "Duplicar",
|
||||
"task_duplicated": "Tarea duplicada",
|
||||
"object_duplicated": "Objeto duplicado",
|
||||
"delete": "Eliminar",
|
||||
"add_task": "+ Tarea",
|
||||
"complete": "Completada",
|
||||
"completed": "Completada",
|
||||
"skip": "Omitir",
|
||||
"skipped": "Omitida",
|
||||
"missed": "Missed",
|
||||
"reset": "Restablecer",
|
||||
"snooze": "Snooze",
|
||||
"snoozed": "Snoozed",
|
||||
"cancel": "Cancelar",
|
||||
"bulk_select": "Seleccionar",
|
||||
"bulk_select_all": "Seleccionar todo",
|
||||
"bulk_n_selected": "{n} seleccionados",
|
||||
"bulk_completed": "{n} tareas completadas",
|
||||
"bulk_archived": "{n} tareas archivadas",
|
||||
"completing": "Completando…",
|
||||
"interval": "Intervalo",
|
||||
"warning": "Aviso",
|
||||
"last_performed": "Última ejecución",
|
||||
"next_due": "Próximo vencimiento",
|
||||
"days_until_due": "Días hasta vencimiento",
|
||||
"avg_duration": "Ø Duración",
|
||||
"trigger": "Disparador",
|
||||
"trigger_type": "Tipo de disparador",
|
||||
"threshold_above": "Límite superior",
|
||||
"threshold_below": "Límite inferior",
|
||||
"threshold": "Umbral",
|
||||
"counter": "Contador",
|
||||
"state_change": "Cambio de estado",
|
||||
"runtime": "Tiempo de funcionamiento",
|
||||
"runtime_hours": "Duración objetivo (horas)",
|
||||
"target_value": "Valor objetivo",
|
||||
"baseline": "Línea base",
|
||||
"target_changes": "Cambios objetivo",
|
||||
"for_minutes": "Durante (minutos)",
|
||||
"time_based": "Temporal",
|
||||
"sensor_based": "Sensor",
|
||||
"manual": "Manual",
|
||||
"one_time": "Una vez",
|
||||
"weekdays": "Días de la semana",
|
||||
"nth_weekday": "N-ésimo día de la semana del mes",
|
||||
"day_of_month": "Día del mes",
|
||||
"recurrence_on_days": "Repetir los",
|
||||
"recurrence_occurrence": "Aparición",
|
||||
"recurrence_weekday": "Día de la semana",
|
||||
"recurrence_day": "Día del mes (1–31)",
|
||||
"recurrence_last_day": "Last day of the month",
|
||||
"recurrence_business_day": "Business days only (roll back from weekend)",
|
||||
"recurrence_offset": "Offset (days, ±)",
|
||||
"recurrence_offset_help": "Shift the date by ±N days, e.g. -2 = two days before.",
|
||||
"last_day_month": "Last day of month",
|
||||
"last_business_day_month": "Last business day",
|
||||
"ord_1": "1.º",
|
||||
"ord_2": "2.º",
|
||||
"ord_3": "3.º",
|
||||
"ord_4": "4.º",
|
||||
"ord_5": "5.º",
|
||||
"ord_last": "Último",
|
||||
"day_word": "Día",
|
||||
"interval_value": "Intervalo",
|
||||
"interval_unit": "Unidad",
|
||||
"unit_days": "Días",
|
||||
"unit_weeks": "Semanas",
|
||||
"unit_months": "Meses",
|
||||
"unit_years": "Años",
|
||||
"due_date": "Fecha de vencimiento",
|
||||
"cleaning": "Limpieza",
|
||||
"inspection": "Inspección",
|
||||
"replacement": "Sustitución",
|
||||
"calibration": "Calibración",
|
||||
"service": "Servicio",
|
||||
"reading": "Lectura",
|
||||
"custom": "Personalizado",
|
||||
"history": "Historial",
|
||||
"cost": "Coste",
|
||||
"report_button": "Informe",
|
||||
"report_title": "Informe de mantenimiento",
|
||||
"report_generated": "Generado",
|
||||
"report_times_done": "Hecho",
|
||||
"report_total_cost": "Coste total",
|
||||
"report_every": "cada {n} {unit}",
|
||||
"report_notes": "Notas",
|
||||
"report_col_type": "Tipo",
|
||||
"report_col_status": "Estado",
|
||||
"report_col_schedule": "Programación",
|
||||
"duration": "Duración",
|
||||
"both": "Ambos",
|
||||
"trigger_val": "Valor del disparador",
|
||||
"complete_title": "Completada: ",
|
||||
"checklist": "Lista de verificación",
|
||||
"checklist_steps_optional": "Pasos de la lista de verificación (opcional)",
|
||||
"checklist_placeholder": "Limpiar filtro\nReemplazar junta\nProbar presión",
|
||||
"checklist_help": "Un paso por línea. Máx. 100 elementos.",
|
||||
"err_too_long": "{field}: demasiado largo (máx. {n} caracteres)",
|
||||
"err_too_short": "{field}: demasiado corto (mín. {n} caracteres)",
|
||||
"err_value_too_high": "{field}: demasiado grande (máx. {n})",
|
||||
"err_value_too_low": "{field}: demasiado pequeño (mín. {n})",
|
||||
"err_required": "{field}: campo obligatorio",
|
||||
"err_wrong_type": "{field}: tipo incorrecto (esperado: {type})",
|
||||
"err_invalid_choice": "{field}: valor no permitido",
|
||||
"err_invalid_value": "{field}: valor inválido",
|
||||
"feat_schedule_time": "Programación por hora",
|
||||
"feat_schedule_time_desc": "Las tareas vencen a una hora específica en lugar de medianoche.",
|
||||
"schedule_time_optional": "Vence a las (opcional, HH:MM)",
|
||||
"schedule_time_help": "Vacío = medianoche (predeterminado). Zona horaria HA.",
|
||||
"at_time": "a las",
|
||||
"notes_optional": "Notas (opcional)",
|
||||
"cost_optional": "Coste (opcional)",
|
||||
"duration_minutes": "Duración en minutos (opcional)",
|
||||
"days": "días",
|
||||
"day": "día",
|
||||
"today": "Hoy",
|
||||
"d_overdue": "d vencida",
|
||||
"no_tasks": "No hay tareas de mantenimiento. Cree un objeto para empezar.",
|
||||
"no_tasks_short": "Sin tareas",
|
||||
"no_history": "Sin entradas en el historial.",
|
||||
"show_all": "Mostrar todo",
|
||||
"cost_duration_chart": "Costes & Duración",
|
||||
"installed": "Instalado",
|
||||
"confirm_delete_object": "¿Eliminar este objeto y todas sus tareas?",
|
||||
"confirm_delete_task": "¿Eliminar esta tarea?",
|
||||
"min": "Mín",
|
||||
"max": "Máx",
|
||||
"save": "Guardar",
|
||||
"saving": "Guardando…",
|
||||
"edit_task": "Editar tarea",
|
||||
"new_task": "Nueva tarea de mantenimiento",
|
||||
"task_name": "Nombre de la tarea",
|
||||
"maintenance_type": "Tipo de mantenimiento",
|
||||
"priority": "Prioridad",
|
||||
"labels": "Labels",
|
||||
"labels_placeholder": "e.g. safety, seasonal, tenant-visible",
|
||||
"labels_help": "Comma-separated tags for filtering and reporting.",
|
||||
"priority_low": "Baja",
|
||||
"priority_normal": "Normal",
|
||||
"priority_high": "Alta",
|
||||
"schedule_type": "Tipo de planificación",
|
||||
"interval_days": "Intervalo (días)",
|
||||
"warning_days": "Días de aviso",
|
||||
"earliest_completion_days": "Earliest completion (days before due)",
|
||||
"earliest_completion_days_help": "Leave empty to allow completing any time. 0 = only on/after the due date.",
|
||||
"last_performed_optional": "Última ejecución (opcional)",
|
||||
"interval_anchor": "Anclaje del intervalo",
|
||||
"anchor_completion": "Desde la fecha de finalización",
|
||||
"anchor_planned": "Desde la fecha planificada (sin desviación)",
|
||||
"edit_object": "Editar objeto",
|
||||
"name": "Nombre",
|
||||
"manufacturer_optional": "Fabricante (opcional)",
|
||||
"model_optional": "Modelo (opcional)",
|
||||
"serial_number_optional": "Número de serie (opcional)",
|
||||
"serial_number_label": "N/S",
|
||||
"documentation_url_label": "Manual",
|
||||
"object_notes_label": "Notas",
|
||||
"sort_due_date": "Vencimiento",
|
||||
"sort_object": "Nombre del objeto",
|
||||
"sort_type": "Tipo",
|
||||
"sort_task_name": "Nombre de la tarea",
|
||||
"all_objects": "Todos los objetos",
|
||||
"tasks_lower": "tareas",
|
||||
"no_tasks_yet": "Aún no hay tareas",
|
||||
"add_first_task": "Agregar primera tarea",
|
||||
"trigger_configuration": "Configuración del disparador",
|
||||
"entity_id": "ID de entidad",
|
||||
"comma_separated": "separados por comas",
|
||||
"entity_logic": "Lógica de entidad",
|
||||
"entity_logic_any": "Cualquier entidad activa",
|
||||
"entity_logic_all": "Todas las entidades deben activar",
|
||||
"entities": "entidades",
|
||||
"attribute_optional": "Atributo (opcional, vacío = estado)",
|
||||
"use_entity_state": "Usar estado de la entidad (sin atributo)",
|
||||
"trigger_above": "Activar por encima de",
|
||||
"trigger_below": "Activar por debajo de",
|
||||
"for_at_least_minutes": "Durante al menos (minutos)",
|
||||
"safety_interval_days": "Intervalo de seguridad (días, opcional)",
|
||||
"safety_interval": "Intervalo de seguridad (opcional)",
|
||||
"delta_mode": "Modo delta",
|
||||
"from_state_optional": "Desde estado (opcional)",
|
||||
"to_state_optional": "Hasta estado (opcional)",
|
||||
"documentation_url_optional": "URL de documentación (opcional)",
|
||||
"object_notes_optional": "Notas (opcional)",
|
||||
"nfc_tag_id_optional": "ID de etiqueta NFC (opcional)",
|
||||
"nfc_tags_empty_help": "Aún no hay tags NFC registrados en Home Assistant.",
|
||||
"nfc_tags_open_settings": "Abrir configuración de tags",
|
||||
"nfc_tags_refresh": "Actualizar",
|
||||
"environmental_entity_optional": "Sensor ambiental (opcional)",
|
||||
"environmental_entity_helper": "p.ej. sensor.temperatura_exterior — ajusta el intervalo según las condiciones ambientales",
|
||||
"environmental_attribute_optional": "Atributo ambiental (opcional)",
|
||||
"nfc_tag_id": "ID de etiqueta NFC",
|
||||
"nfc_linked": "Etiqueta NFC vinculada",
|
||||
"nfc_link_hint": "Clic para vincular etiqueta NFC",
|
||||
"responsible_user": "Usuario responsable",
|
||||
"shared_with": "Shared with (rotation)",
|
||||
"shared_with_help": "Pick multiple people to share this task; the responsible person rotates on each completion.",
|
||||
"rotation_strategy": "Rotation",
|
||||
"rotation_none": "No rotation",
|
||||
"rotation_round_robin": "Round-robin",
|
||||
"rotation_least_completed": "Least completed",
|
||||
"rotation_random": "Random",
|
||||
"no_user_assigned": "(Ningún usuario asignado)",
|
||||
"all_users": "Todos los usuarios",
|
||||
"my_tasks": "Mis tareas",
|
||||
"tab_calendar": "Calendario",
|
||||
"cal_no_events": "Sin mantenimiento",
|
||||
"cal_window_7": "7 días",
|
||||
"cal_window_14": "14 días",
|
||||
"cal_window_30": "30 días",
|
||||
"cal_window_365": "1 año",
|
||||
"cal_every_n_days": "cada {n} días",
|
||||
"cal_source_time": "Basado en tiempo",
|
||||
"cal_source_time_adaptive": "Basado en tiempo (adaptativo)",
|
||||
"cal_source_sensor": "Basado en sensor",
|
||||
"cal_predicted": "predicho",
|
||||
"cal_confidence_high": "alta confianza",
|
||||
"cal_confidence_medium": "confianza media",
|
||||
"cal_confidence_low": "baja confianza",
|
||||
"budget_monthly": "Presupuesto mensual",
|
||||
"budget_yearly": "Presupuesto anual",
|
||||
"groups": "Grupos",
|
||||
"new_group": "Nuevo grupo",
|
||||
"edit_group": "Editar grupo",
|
||||
"no_groups": "Sin grupos todavía",
|
||||
"delete_group": "Eliminar grupo",
|
||||
"delete_group_confirm": "¿Eliminar el grupo '{name}'?",
|
||||
"group_select_tasks": "Seleccionar tareas",
|
||||
"group_name_required": "Nombre requerido",
|
||||
"description_optional": "Descripción (opcional)",
|
||||
"selected": "Seleccionado",
|
||||
"loading_chart": "Cargando datos...",
|
||||
"hide_outliers": "Ocultar valores atípicos (fallos del sensor)",
|
||||
"was_maintenance_needed": "¿Era necesario este mantenimiento?",
|
||||
"feedback_needed": "Necesario",
|
||||
"feedback_not_needed": "No necesario",
|
||||
"feedback_not_sure": "No seguro",
|
||||
"suggested_interval": "Intervalo sugerido",
|
||||
"apply_suggestion": "Aplicar",
|
||||
"reanalyze": "Reanalizar",
|
||||
"reanalyze_result": "Nuevo análisis",
|
||||
"reanalyze_insufficient_data": "Datos insuficientes para una recomendación",
|
||||
"data_points": "puntos de datos",
|
||||
"dismiss_suggestion": "Descartar",
|
||||
"confidence_low": "Baja",
|
||||
"confidence_medium": "Media",
|
||||
"confidence_high": "Alta",
|
||||
"recommended": "recomendado",
|
||||
"seasonal_awareness": "Conciencia estacional",
|
||||
"edit_seasonal_overrides": "Editar factores estacionales",
|
||||
"seasonal_overrides_title": "Factores estacionales (override)",
|
||||
"seasonal_overrides_hint": "Factor por mes (0.1–5.0). Vacío = aprendido automáticamente.",
|
||||
"seasonal_override_invalid": "Valor no válido",
|
||||
"seasonal_override_range": "El factor debe estar entre 0.1 y 5.0",
|
||||
"clear_all": "Borrar todo",
|
||||
"seasonal_chart_title": "Factores estacionales",
|
||||
"seasonal_learned": "Aprendido",
|
||||
"seasonal_manual": "Manual",
|
||||
"month_jan": "Ene",
|
||||
"month_feb": "Feb",
|
||||
"month_mar": "Mar",
|
||||
"month_apr": "Abr",
|
||||
"month_may": "May",
|
||||
"month_jun": "Jun",
|
||||
"month_jul": "Jul",
|
||||
"month_aug": "Ago",
|
||||
"month_sep": "Sep",
|
||||
"month_oct": "Oct",
|
||||
"month_nov": "Nov",
|
||||
"month_dec": "Dic",
|
||||
"sensor_prediction": "Predicción del sensor",
|
||||
"degradation_trend": "Tendencia",
|
||||
"trend_rising": "En aumento",
|
||||
"trend_falling": "En descenso",
|
||||
"trend_stable": "Estable",
|
||||
"trend_insufficient_data": "Datos insuficientes",
|
||||
"days_until_threshold": "Días hasta el umbral",
|
||||
"threshold_exceeded": "Umbral superado",
|
||||
"environmental_adjustment": "Factor ambiental",
|
||||
"sensor_prediction_urgency": "El sensor predice el umbral en ~{days} días",
|
||||
"day_short": "día",
|
||||
"weibull_reliability_curve": "Curva de fiabilidad",
|
||||
"weibull_failure_probability": "Probabilidad de fallo",
|
||||
"weibull_r_squared": "Ajuste R²",
|
||||
"beta_early_failures": "Fallos tempranos",
|
||||
"beta_random_failures": "Fallos aleatorios",
|
||||
"beta_wear_out": "Desgaste",
|
||||
"beta_highly_predictable": "Altamente predecible",
|
||||
"confidence_interval": "Intervalo de confianza",
|
||||
"confidence_conservative": "Conservador",
|
||||
"confidence_aggressive": "Optimista",
|
||||
"current_interval_marker": "Intervalo actual",
|
||||
"recommended_marker": "Recomendado",
|
||||
"characteristic_life": "Vida característica",
|
||||
"chart_mini_sparkline": "Sparkline de tendencia",
|
||||
"chart_history": "Historial de costes y duración",
|
||||
"chart_seasonal": "Factores estacionales, 12 meses",
|
||||
"chart_weibull": "Curva de fiabilidad Weibull",
|
||||
"chart_sparkline": "Gráfico de valor del disparador",
|
||||
"days_progress": "Progreso en días",
|
||||
"qr_code": "Código QR",
|
||||
"qr_generating": "Generando código QR…",
|
||||
"qr_error": "No se pudo generar el código QR.",
|
||||
"qr_error_no_url": "No hay URL de HA configurada. Establezca una URL externa o interna en Ajustes → Sistema → Red.",
|
||||
"save_error": "Error al guardar. Inténtelo de nuevo.",
|
||||
"qr_print": "Imprimir",
|
||||
"qr_download": "Descargar SVG",
|
||||
"qr_action": "Acción al escanear",
|
||||
"qr_action_view": "Ver info de mantenimiento",
|
||||
"qr_action_complete": "Marcar mantenimiento como completado",
|
||||
"qr_url_mode": "Tipo de enlace",
|
||||
"qr_mode_companion": "Companion App",
|
||||
"qr_mode_local": "Local (mDNS)",
|
||||
"qr_mode_server": "URL del servidor",
|
||||
"overview": "Resumen",
|
||||
"analysis": "Análisis",
|
||||
"recent_activities": "Actividades recientes",
|
||||
"search_notes": "Buscar en notas",
|
||||
"avg_cost": "Ø Coste",
|
||||
"no_advanced_features": "Sin funciones avanzadas activadas",
|
||||
"no_advanced_features_hint": "Active “Intervalos Adaptativos” o “Patrones Estacionales” en la configuración de la integración para ver datos de análisis aquí.",
|
||||
"analysis_not_enough_data": "Aún no hay suficientes datos para el análisis.",
|
||||
"analysis_not_enough_data_hint": "El análisis Weibull requiere al menos 5 mantenimientos completados; los patrones estacionales son visibles tras 6+ puntos de datos por mes.",
|
||||
"analysis_manual_task_hint": "Las tareas manuales sin intervalo no generan datos de análisis.",
|
||||
"completions": "finalizaciones",
|
||||
"current": "Actual",
|
||||
"shorter": "Más corto",
|
||||
"longer": "Más largo",
|
||||
"normal": "Normal",
|
||||
"disabled": "Desactivado",
|
||||
"compound_logic": "Lógica compuesta",
|
||||
"compound": "Compuesto (varias condiciones)",
|
||||
"compound_logic_and": "Y — todas las condiciones deben activarse",
|
||||
"compound_logic_or": "O — basta con una condición",
|
||||
"compound_help": "Combina varias condiciones de sensores en un disparador.",
|
||||
"compound_no_conditions": "Aún no hay condiciones — añade al menos una.",
|
||||
"compound_add_condition": "Añadir condición",
|
||||
"compound_condition": "Condición",
|
||||
"compound_remove_condition": "Eliminar condición",
|
||||
"card_title": "Título",
|
||||
"card_show_header": "Mostrar encabezado con estadísticas",
|
||||
"card_show_actions": "Mostrar botones de acción",
|
||||
"card_compact": "Modo compacto",
|
||||
"card_max_items": "Máx. elementos (0 = todos)",
|
||||
"card_filter_status": "Filtrar por estado",
|
||||
"card_filter_status_help": "Vacío = mostrar todos los estados.",
|
||||
"card_filter_objects": "Filtrar por objetos",
|
||||
"card_filter_objects_help": "Vacío = mostrar todos los objetos.",
|
||||
"card_filter_entities": "Filtrar por entidades (entity_ids)",
|
||||
"card_filter_entities_help": "Selecciona entidades sensor / binary_sensor de esta integración. Vacío = todas.",
|
||||
"card_loading_objects": "Cargando objetos…",
|
||||
"card_load_error": "No se pudieron cargar los objetos — verifica la conexión WebSocket.",
|
||||
"card_no_tasks_title": "Aún no hay tareas de mantenimiento",
|
||||
"card_no_tasks_cta": "→ Crea una en el panel Mantenimiento",
|
||||
"no_objects": "Aún no hay objetos.",
|
||||
"action_error": "Acción fallida. Inténtelo de nuevo.",
|
||||
"area_id_optional": "Área (opcional)",
|
||||
"installation_date_optional": "Fecha de instalación (opcional)",
|
||||
"warranty_expiry_optional": "Vencimiento de garantía (opcional)",
|
||||
"warranty": "Garantía",
|
||||
"warranty_valid_until": "válida hasta {date}",
|
||||
"warranty_expires_in": "vence en {days} días",
|
||||
"warranty_expired": "vencida",
|
||||
"cal_past_windows": "Ventanas pasadas",
|
||||
"cal_forward_windows": "Ventanas futuras",
|
||||
"history_edit_title": "Editar entrada del historial",
|
||||
"history_edit_timestamp": "Marca de tiempo",
|
||||
"manufacturer": "Fabricante",
|
||||
"model": "Modelo",
|
||||
"area": "Área",
|
||||
"actions": "Acciones",
|
||||
"view_mode_label": "Vista",
|
||||
"view_cards": "Vista de tarjetas",
|
||||
"view_table": "Vista de tabla",
|
||||
"objects_table_columns_label": "Columnas de la tabla de objetos",
|
||||
"objects_table_columns_hint": "Elige qué columnas aparecen en la vista de tabla de objetos.",
|
||||
"custom_icon_optional": "Icono (opcional, ej. mdi:wrench)",
|
||||
"task_enabled": "Tarea habilitada",
|
||||
"skip_reason_prompt": "¿Omitir esta tarea?",
|
||||
"reason_optional": "Motivo (opcional)",
|
||||
"reset_date_prompt": "¿Marcar la tarea como realizada?",
|
||||
"reset_date_optional": "Fecha de última ejecución (opcional, por defecto: hoy)",
|
||||
"notes_label": "Notas",
|
||||
"documentation_label": "Documentación",
|
||||
"no_nfc_tag": "— Sin etiqueta —",
|
||||
"dashboard": "Panel",
|
||||
"tab_today": "Hoy",
|
||||
"palette_placeholder": "Buscar objetos y tareas…",
|
||||
"palette_no_results": "Sin resultados",
|
||||
"palette_hint": "↑↓ navegar · Enter abrir · Esc cerrar",
|
||||
"today_all_caught_up": "¡Todo al día! Nada pendiente esta semana.",
|
||||
"today_overdue": "Vencidas",
|
||||
"today_due_today": "Vence hoy",
|
||||
"today_this_week": "Esta semana",
|
||||
"settings": "Ajustes",
|
||||
"settings_features": "Funciones avanzadas",
|
||||
"settings_features_desc": "Active o desactive funciones avanzadas. Desactivar las oculta de la interfaz pero no elimina datos.",
|
||||
"feat_adaptive": "Planificación adaptativa",
|
||||
"feat_adaptive_desc": "Aprender intervalos óptimos del historial de mantenimiento",
|
||||
"feat_predictions": "Predicciones de sensor",
|
||||
"feat_predictions_desc": "Predecir fechas de activación por degradación del sensor",
|
||||
"feat_seasonal": "Ajustes estacionales",
|
||||
"feat_seasonal_desc": "Ajustar intervalos según patrones estacionales",
|
||||
"feat_environmental": "Correlación ambiental",
|
||||
"feat_environmental_desc": "Correlacionar intervalos con temperatura/humedad",
|
||||
"feat_budget": "Seguimiento de presupuesto",
|
||||
"feat_budget_desc": "Seguir los gastos de mantenimiento mensuales y anuales",
|
||||
"feat_groups": "Grupos de tareas",
|
||||
"feat_groups_desc": "Organizar tareas en grupos lógicos",
|
||||
"feat_checklists": "Listas de verificación",
|
||||
"feat_checklists_desc": "Procedimientos de varios pasos para completar tareas",
|
||||
"settings_general": "General",
|
||||
"settings_default_warning": "Días de aviso predeterminados",
|
||||
"settings_panel_enabled": "Panel lateral",
|
||||
"settings_panel_title": "Título del panel lateral",
|
||||
"settings_notifications": "Notificaciones",
|
||||
"settings_notify_service": "Servicio de notificación",
|
||||
"test_notification": "Notificación de prueba",
|
||||
"send_test": "Enviar prueba",
|
||||
"testing": "Enviando…",
|
||||
"test_notification_success": "Notificación de prueba enviada",
|
||||
"test_notification_failed": "La notificación de prueba falló",
|
||||
"settings_notify_due_soon": "Notificar cuando esté próxima",
|
||||
"settings_notify_overdue": "Notificar cuando esté vencida",
|
||||
"settings_notify_triggered": "Notificar cuando se active",
|
||||
"settings_interval_hours": "Intervalo de repetición (horas, 0 = una vez)",
|
||||
"settings_quiet_hours": "Horas de silencio",
|
||||
"settings_quiet_start": "Inicio",
|
||||
"settings_quiet_end": "Fin",
|
||||
"settings_max_per_day": "Máx. notificaciones por día (0 = ilimitado)",
|
||||
"settings_bundling": "Agrupar notificaciones",
|
||||
"settings_bundle_threshold": "Umbral de agrupación",
|
||||
"settings_reminder_leads": "Extra reminders (days before due)",
|
||||
"settings_reminder_leads_hint": "Comma-separated lead times, e.g. 14, 3, 0 — one extra reminder fires on each matching day. Empty = off.",
|
||||
"settings_actions": "Botones de acción móviles",
|
||||
"settings_action_complete": "Mostrar botón 'Completada'",
|
||||
"settings_action_skip": "Mostrar botón 'Omitir'",
|
||||
"settings_action_snooze": "Mostrar botón 'Posponer'",
|
||||
"settings_weekly_digest": "Resumen semanal",
|
||||
"settings_weekly_digest_hint": "Una notificación de resumen el lunes por la mañana cuando hay tareas.",
|
||||
"settings_warranty_reminder": "Warranty expiry reminder",
|
||||
"settings_warranty_reminder_days": "Days before expiry",
|
||||
"settings_warranty_reminder_hint": "Notify once when an object's warranty is this many days from expiring.",
|
||||
"settings_snooze_hours": "Duración de posposición (horas)",
|
||||
"settings_budget": "Presupuesto",
|
||||
"settings_currency": "Moneda",
|
||||
"settings_budget_monthly": "Presupuesto mensual",
|
||||
"settings_budget_yearly": "Presupuesto anual",
|
||||
"settings_budget_alerts": "Alertas de presupuesto",
|
||||
"settings_budget_threshold": "Umbral de alerta (%)",
|
||||
"settings_import_export": "Importar / Exportar",
|
||||
"settings_export_json": "Exportar JSON",
|
||||
"settings_export_yaml": "Exportar YAML",
|
||||
"settings_export_csv": "Exportar CSV",
|
||||
"settings_import_csv": "Importar CSV",
|
||||
"settings_import_placeholder": "Pegue el contenido JSON o CSV aquí…",
|
||||
"settings_import_btn": "Importar",
|
||||
"settings_import_success": "{count} objetos importados correctamente.",
|
||||
"settings_export_success": "Exportación descargada.",
|
||||
"settings_saved": "Ajuste guardado.",
|
||||
"settings_include_history": "Incluir historial",
|
||||
"sort_alphabetical": "Alfabético",
|
||||
"sort_due_soonest": "Próximo a vencer",
|
||||
"sort_task_count": "Cantidad de tareas",
|
||||
"sort_area": "Área",
|
||||
"sort_assigned_user": "Usuario asignado",
|
||||
"sort_group": "Grupo",
|
||||
"groupby_none": "Sin agrupación",
|
||||
"groupby_area": "Por área",
|
||||
"groupby_group": "Por grupo",
|
||||
"groupby_user": "Por usuario",
|
||||
"filter_label": "Filtro",
|
||||
"user_label": "Usuario",
|
||||
"sort_label": "Ordenar",
|
||||
"group_by_label": "Agrupar por",
|
||||
"state_value_help": "Usa el valor de estado de HA (normalmente en minúsculas, p. ej. \"on\"/\"off\"). Las mayúsculas se normalizan al guardar.",
|
||||
"target_changes_help": "Número de transiciones coincidentes antes de que se dispare el trigger (predeterminado: 1).",
|
||||
"qr_print_title": "Imprimir códigos QR",
|
||||
"qr_print_desc": "Genera una página imprimible de códigos QR para recortar y pegar en tus equipos.",
|
||||
"qr_print_load": "Cargar objetos",
|
||||
"qr_print_filter": "Filtro",
|
||||
"qr_print_objects": "Objetos",
|
||||
"qr_print_actions": "Acciones",
|
||||
"qr_print_url_mode": "Tipo de enlace",
|
||||
"qr_print_estimate": "Códigos QR estimados",
|
||||
"qr_print_over_limit": "máximo 200, reduce el filtro",
|
||||
"qr_print_generate": "Generar códigos QR",
|
||||
"qr_print_generating": "Generando…",
|
||||
"qr_print_ready": "Códigos QR listos",
|
||||
"qr_print_print_button": "Imprimir",
|
||||
"qr_print_empty": "Nada que generar",
|
||||
"qr_action_skip": "Omitir",
|
||||
"vacation_title": "Modo vacaciones",
|
||||
"vacation_active": "activo",
|
||||
"vacation_ended": "terminado",
|
||||
"vacation_desc": "Planifica tus vacaciones: las notificaciones se pausan durante el período más unos días de margen. Puedes excluir tareas concretas.",
|
||||
"vacation_enable": "Activar modo vacaciones",
|
||||
"vacation_start": "Inicio",
|
||||
"vacation_end": "Fin",
|
||||
"vacation_buffer": "Margen (días)",
|
||||
"vacation_exempt_title": "Notificar igual durante vacaciones",
|
||||
"vacation_exempt_desc": "Selecciona tareas que deben notificar también en vacaciones (p. ej. química crítica de piscina).",
|
||||
"vacation_load_tasks": "Cargar tareas",
|
||||
"vacation_preview_btn": "Mostrar vista previa",
|
||||
"vacation_preview_affected": "tareas afectadas",
|
||||
"vacation_event_due_soon": "vencerá pronto",
|
||||
"vacation_event_overdue": "se volverá vencida",
|
||||
"vacation_event_triggered_est": "posible activación de sensor",
|
||||
"vacation_sensor_based": "(basado en sensor)",
|
||||
"vacation_action_notify": "Notificar igual",
|
||||
"vacation_action_unsilence": "Silenciar de nuevo",
|
||||
"vacation_marked_complete": "Marcado como completado",
|
||||
"vacation_marked_skip": "Omitido",
|
||||
"vacation_end_now": "Terminar vacaciones ahora",
|
||||
"unassigned": "Sin asignar",
|
||||
"no_area": "Sin área",
|
||||
"has_overdue": "Tareas vencidas",
|
||||
"object": "Objeto",
|
||||
"settings_panel_access": "Acceso al panel",
|
||||
"settings_panel_access_desc": "Los administradores siempre tienen acceso completo. Para delegar crear, editar y eliminar a no administradores específicos, activa esto y selecciónalos abajo — los demás solo ven Completar y Omitir.",
|
||||
"settings_operator_write": "Permitir a los usuarios seleccionados crear, editar y eliminar",
|
||||
"settings_operator_write_desc": "Desactivado: solo los administradores pueden cambiar el contenido. Activado: los usuarios seleccionados abajo también tienen acceso completo.",
|
||||
"no_non_admin_users": "No se encontraron usuarios no administradores. Añade alguno en Ajustes → Personas.",
|
||||
"owner_label": "Propietario",
|
||||
"feat_completion_actions": "Acciones de finalización",
|
||||
"feat_completion_actions_desc": "Acción HA por tarea al completar + QR de finalización rápida con valores prefijados.",
|
||||
"on_complete_action_title": "Al completar: ejecutar acción HA (opcional)",
|
||||
"on_complete_action_desc": "Llama un servicio HA cuando se completa la tarea — p. ej. reiniciar un contador del dispositivo.",
|
||||
"on_complete_action_service": "Servicio",
|
||||
"on_complete_action_target": "Entidad objetivo",
|
||||
"on_complete_action_data": "Datos (JSON, opcional)",
|
||||
"on_complete_action_test": "Probar acción",
|
||||
"on_complete_action_test_success": "Éxito",
|
||||
"on_complete_action_test_failed": "Fallido",
|
||||
"quick_complete_defaults_title": "Valores predeterminados de finalización rápida (para escaneos QR, opcional)",
|
||||
"quick_complete_defaults_desc": "Valores prefijados para QR de finalización rápida. Sin ellos, el QR abre el diálogo de completar.",
|
||||
"quick_complete_defaults_notes": "Notas",
|
||||
"quick_complete_defaults_cost": "Coste",
|
||||
"quick_complete_defaults_duration": "Duración (minutos)",
|
||||
"quick_complete_defaults_feedback_none": "Sin feedback",
|
||||
"quick_complete_defaults_feedback_needed": "Era necesario",
|
||||
"quick_complete_defaults_feedback_not_needed": "No era necesario",
|
||||
"quick_complete_success": "Completado rápido",
|
||||
"trigger_replaced": "Disparador reemplazado",
|
||||
"add": "Añadir",
|
||||
"show_stats": "Mostrar estadísticas + gráficos",
|
||||
"hide_stats": "Ocultar estadísticas",
|
||||
"adaptive_no_data": "Aún no hay suficiente historial de finalizaciones para el análisis adaptativo. Completa esta tarea unas cuantas veces más para desbloquear las recomendaciones de intervalo y los gráficos de fiabilidad.",
|
||||
"suggestion_applied": "Intervalo sugerido aplicado",
|
||||
"vacation_mode": "Modo vacaciones",
|
||||
"vacation_status_active": "Activo ahora",
|
||||
"vacation_status_scheduled": "Programado",
|
||||
"vacation_status_inactive": "Inactivo",
|
||||
"vacation_end_now_confirm": "¿Finalizar las vacaciones de inmediato?",
|
||||
"vacation_exempt_count": "exentas",
|
||||
"vacation_advanced": "Avanzado…",
|
||||
"vacation_open_panel": "Abrir en el panel",
|
||||
"enable": "Activar",
|
||||
"saved": "Guardado",
|
||||
"budget_monthly_set": "Definir mensual",
|
||||
"budget_yearly_set": "Definir anual",
|
||||
"budget_advanced": "Moneda, alertas…",
|
||||
"budget_open_panel": "Abrir en el panel",
|
||||
"groups_empty": "Aún no hay grupos.",
|
||||
"group_new_placeholder": "Añadir grupo…",
|
||||
"group_delete_confirm": "¿Eliminar el grupo \"{name}\"?",
|
||||
"groups_manage_tasks": "Gestionar asignaciones de tareas…",
|
||||
"groups_open_panel": "Abrir en el panel",
|
||||
"on_complete_action_target_hint": "Nota: el dominio de la entidad debe coincidir con el servicio — p. ej. 'button.press' solo funciona en button.*, 'counter.increment' solo en counter.*, 'input_button.press' solo en input_button.* etc. Si no coinciden, la acción falla silenciosamente (HA registra 'Referenced entities ... missing or not currently available').",
|
||||
"show_all_objects": "Mostrar todos los objetos",
|
||||
"show_all_tasks": "Quitar filtro — mostrar todas las tareas",
|
||||
"filter_to_overdue": "Filtrar la lista solo a vencidas",
|
||||
"filter_to_due_soon": "Filtrar la lista solo a próximas a vencer",
|
||||
"filter_to_triggered": "Filtrar la lista solo a activadas",
|
||||
"open_task": "Abrir tarea",
|
||||
"show_details": "Mostrar historial + estadísticas",
|
||||
"hide_details": "Ocultar detalles",
|
||||
"history_empty": "Aún no hay historial.",
|
||||
"history_edit_button": "Editar entrada",
|
||||
"total_cost": "Coste total",
|
||||
"times_performed": "Realizada",
|
||||
"older_entries": "anteriores",
|
||||
"open_in_panel": "Abrir en el panel de Mantenimiento",
|
||||
"skip_reason": "Motivo de omisión (opcional)",
|
||||
"reset_to_date": "Restablecer última ejecución a",
|
||||
"delete_task_confirm": "¿Eliminar esta tarea y su historial?",
|
||||
"delete_object_confirm": "¿Eliminar este objeto y todas sus tareas?",
|
||||
"loading": "Cargando…",
|
||||
"archive": "Archivar",
|
||||
"undo": "Deshacer",
|
||||
"task_archived": "Tarea archivada",
|
||||
"object_archived": "Objeto archivado",
|
||||
"unarchive": "Desarchivar",
|
||||
"archived": "Archivado",
|
||||
"show_archived": "Mostrar archivados",
|
||||
"hide_archived": "Ocultar archivados",
|
||||
"confirm_archive_object": "¿Archivar este objeto y sus tareas? Se conserva el historial y se puede restaurar más tarde.",
|
||||
"settings_archive": "Archivado y retención",
|
||||
"settings_archive_desc": "Retira las tareas únicas completadas sin eliminarlas. Los elementos archivados se ocultan y quedan inactivos, pero conservan su historial y coste.",
|
||||
"settings_archive_oneoff_days": "Archivar automáticamente las tareas únicas completadas tras (días, 0 = desactivado)",
|
||||
"settings_delete_archived_oneoff_days": "Eliminar automáticamente las tareas únicas archivadas tras (días, 0 = nunca)",
|
||||
"archive_object": "Archivar objeto",
|
||||
"unarchive_object": "Desarchivar objeto",
|
||||
"documents": "Documentos",
|
||||
"documents_empty": "Aún no hay documentos.",
|
||||
"doc_upload": "Subir archivo",
|
||||
"doc_uploading": "Subiendo…",
|
||||
"doc_add_link": "Añadir enlace",
|
||||
"doc_link_url": "URL (https://…)",
|
||||
"doc_link_title": "Título (opcional)",
|
||||
"doc_open": "Abrir",
|
||||
"doc_delete_confirm": "¿Eliminar «{name}»?",
|
||||
"doc_too_large": "El archivo es demasiado grande (máx. 25 MB).",
|
||||
"doc_upload_failed": "Error al subir.",
|
||||
"completion_photo_optional": "Completion photo (optional)",
|
||||
"add_photo": "Add photo",
|
||||
"uploading": "Uploading…",
|
||||
"remove": "Remove",
|
||||
"doc_deduped": "Ya almacenado en otro lugar — compartido, sin espacio adicional.",
|
||||
"doc_dup_in_object": "Este archivo ya está adjunto a este objeto.",
|
||||
"doc_link_invalid": "Solo se permiten enlaces http/https.",
|
||||
"doc_cat_manual": "Manual",
|
||||
"doc_cat_warranty": "Garantía",
|
||||
"doc_cat_invoice": "Factura",
|
||||
"doc_cat_spare_parts": "Repuestos",
|
||||
"doc_cat_photo": "Foto",
|
||||
"doc_cat_other": "Otro",
|
||||
"doc_link_badge": "Enlace",
|
||||
"doc_storage_title": "Almacenamiento de documentos",
|
||||
"doc_storage_saved": "Ahorrado por deduplicación",
|
||||
"doc_storage_refresh": "Actualizar",
|
||||
"doc_download": "Descargar",
|
||||
"doc_close": "Cerrar",
|
||||
"doc_camera": "Tomar foto",
|
||||
"doc_drop_hint": "Suelta los archivos aquí",
|
||||
"doc_task_none": "Ningún documento vinculado a esta tarea.",
|
||||
"doc_link_existing": "Vincular un documento…",
|
||||
"doc_attach": "Vincular",
|
||||
"doc_unlink": "Desvincular",
|
||||
"doc_page": "Página",
|
||||
"chart_range_7d": "7d",
|
||||
"chart_range_30d": "30d",
|
||||
"chart_range_90d": "90d",
|
||||
"chart_range_1y": "1a",
|
||||
"chart_since_service": "desde el último mantenimiento",
|
||||
"chart_no_stats": "No hay estadísticas a largo plazo para esta entidad — solo se muestran valores de eventos de mantenimiento",
|
||||
"auto_complete_on_recovery": "Completar automáticamente cuando el sensor se recupere",
|
||||
"auto_complete_on_recovery_help": "Registra una finalización (fija la última realización) cuando el disparador se resuelve solo — p. ej., sal rellenada, filtro cambiado.",
|
||||
"doc_search": "Buscar documentos…",
|
||||
"doc_search_none": "Ningún documento coincidente",
|
||||
"link_device_optional": "Vincular a dispositivo existente (opcional)",
|
||||
"parent_object_optional": "Objeto principal (opcional)",
|
||||
"parent_none": "(Sin objeto principal)",
|
||||
"paused": "Pausado",
|
||||
"pause_object": "Pausar",
|
||||
"resume_object": "Reanudar",
|
||||
"pause_until_prompt": "Congela los calendarios de este objeto: nada vence ni notifica hasta reanudarlo. Opcionalmente, fija una fecha de reanudación automática.",
|
||||
"pause_until_label": "Reanudar el (opcional)",
|
||||
"object_paused": "Objeto pausado",
|
||||
"object_resumed": "Objeto reanudado — calendarios reiniciados",
|
||||
"object_paused_badge": "Pausado",
|
||||
"paused_until_label": "hasta",
|
||||
"replace_object": "Sustituir…",
|
||||
"replace_object_prompt": "Retira este objeto y crea un sucesor. El historial y los costes quedan archivados en el antiguo; las tareas y los documentos pasan al nuevo, los contadores empiezan de cero.",
|
||||
"replace_name_label": "Nombre del sucesor",
|
||||
"object_replaced": "Objeto sustituido — sucesor creado",
|
||||
"reading_unit_label": "Unidad de lectura (p. ej. kWh, m³)",
|
||||
"reading_unit_help": "Se muestra junto al valor registrado al completar esta tarea.",
|
||||
"reading_value_label": "Valor de lectura",
|
||||
"reading_label": "Lectura",
|
||||
"settings_templates_label": "Galería de plantillas",
|
||||
"settings_templates_hint": "Desmarca las plantillas que nunca necesitarás: desaparecen de los selectores \"Desde plantilla\" (panel y flujo de configuración). Nada más cambia; puedes reactivarlas cuando quieras.",
|
||||
"worksheet": "Hoja de trabajo",
|
||||
"worksheet_scan_view": "Escanea para abrir la tarea",
|
||||
"worksheet_scan_complete": "Escanea para completar",
|
||||
"worksheet_manual_excerpt": "Extracto del manual",
|
||||
"worksheet_pages": "páginas",
|
||||
"worksheet_printed": "Impreso",
|
||||
"worksheet_never": "Nunca"
|
||||
}
|
||||
@@ -0,0 +1,696 @@
|
||||
{
|
||||
"maintenance": "Huolto",
|
||||
"objects": "Kohteet",
|
||||
"tasks": "Tehtävät",
|
||||
"overdue": "Myöhässä",
|
||||
"due_soon": "Pian erääntyy",
|
||||
"triggered": "Laukaistu",
|
||||
"trigger_replaced": "Laukaisin korvattu",
|
||||
"ok": "OK",
|
||||
"all": "Kaikki",
|
||||
"new_object": "+ Uusi kohde",
|
||||
"templates_from": "Mallista",
|
||||
"templates_title": "Aloita mallista",
|
||||
"templates_task_count": "{n} tehtävää",
|
||||
"template_created": "Luotu mallista",
|
||||
"onboard_hint": "Lisää ensimmäinen kohde ja aloita huollon seuranta.",
|
||||
"edit": "Muokkaa",
|
||||
"duplicate": "Monista",
|
||||
"task_duplicated": "Tehtävä monistettu",
|
||||
"object_duplicated": "Kohde monistettu",
|
||||
"delete": "Poista",
|
||||
"add_task": "+ Lisää tehtävä",
|
||||
"complete": "Valmis",
|
||||
"completed": "Suoritettu",
|
||||
"skip": "Ohita",
|
||||
"skipped": "Ohitettu",
|
||||
"missed": "Missed",
|
||||
"reset": "Nollaa",
|
||||
"snooze": "Snooze",
|
||||
"snoozed": "Snoozed",
|
||||
"cancel": "Peruuta",
|
||||
"bulk_select": "Valitse",
|
||||
"bulk_select_all": "Valitse kaikki",
|
||||
"bulk_n_selected": "{n} valittu",
|
||||
"bulk_completed": "{n} tehtävää valmiina",
|
||||
"bulk_archived": "{n} tehtävää arkistoitu",
|
||||
"completing": "Suoritetaan…",
|
||||
"interval": "Väli",
|
||||
"warning": "Varoitus",
|
||||
"last_performed": "Viimeksi suoritettu",
|
||||
"next_due": "Seuraava eräpäivä",
|
||||
"days_until_due": "Päiviä eräpäivään",
|
||||
"avg_duration": "Keskim. kesto",
|
||||
"trigger": "Laukaisin",
|
||||
"trigger_type": "Laukaisimen tyyppi",
|
||||
"threshold_above": "Yläraja",
|
||||
"threshold_below": "Alaraja",
|
||||
"threshold": "Kynnysarvo",
|
||||
"counter": "Laskuri",
|
||||
"state_change": "Tilan muutos",
|
||||
"runtime": "Käyttöaika",
|
||||
"runtime_hours": "Tavoitekäyttöaika (tuntia)",
|
||||
"target_value": "Tavoitearvo",
|
||||
"baseline": "Lähtötaso",
|
||||
"target_changes": "Tavoitemuutokset",
|
||||
"for_minutes": "Vähintään (minuuttia)",
|
||||
"time_based": "Aikaperusteinen",
|
||||
"sensor_based": "Anturiperusteinen",
|
||||
"manual": "Manuaalinen",
|
||||
"one_time": "Kertaluonteinen",
|
||||
"weekdays": "Viikonpäivät",
|
||||
"nth_weekday": "Kuukauden n:s viikonpäivä",
|
||||
"day_of_month": "Kuukauden päivä",
|
||||
"recurrence_on_days": "Toista päivinä",
|
||||
"recurrence_occurrence": "Esiintymä",
|
||||
"recurrence_weekday": "Viikonpäivä",
|
||||
"recurrence_day": "Kuukauden päivä (1–31)",
|
||||
"recurrence_last_day": "Last day of the month",
|
||||
"recurrence_business_day": "Business days only (roll back from weekend)",
|
||||
"recurrence_offset": "Offset (days, ±)",
|
||||
"recurrence_offset_help": "Shift the date by ±N days, e.g. -2 = two days before.",
|
||||
"last_day_month": "Last day of month",
|
||||
"last_business_day_month": "Last business day",
|
||||
"ord_1": "1.",
|
||||
"ord_2": "2.",
|
||||
"ord_3": "3.",
|
||||
"ord_4": "4.",
|
||||
"ord_5": "5.",
|
||||
"ord_last": "Viimeinen",
|
||||
"day_word": "Päivä",
|
||||
"interval_value": "Väli",
|
||||
"interval_unit": "Yksikkö",
|
||||
"unit_days": "Päivää",
|
||||
"unit_weeks": "Viikkoa",
|
||||
"unit_months": "Kuukautta",
|
||||
"unit_years": "Vuotta",
|
||||
"due_date": "Eräpäivä",
|
||||
"cleaning": "Puhdistus",
|
||||
"inspection": "Tarkastus",
|
||||
"replacement": "Vaihto",
|
||||
"calibration": "Kalibrointi",
|
||||
"service": "Huolto",
|
||||
"reading": "Lukema",
|
||||
"custom": "Mukautettu",
|
||||
"history": "Historia",
|
||||
"cost": "Kustannus",
|
||||
"report_button": "Raportti",
|
||||
"report_title": "Huoltoraportti",
|
||||
"report_generated": "Luotu",
|
||||
"report_times_done": "Tehty",
|
||||
"report_total_cost": "Kokonaiskustannus",
|
||||
"report_every": "joka {n}. {unit}",
|
||||
"report_notes": "Muistiinpanot",
|
||||
"report_col_type": "Tyyppi",
|
||||
"report_col_status": "Tila",
|
||||
"report_col_schedule": "Aikataulu",
|
||||
"duration": "Kesto",
|
||||
"both": "Molemmat",
|
||||
"trigger_val": "Laukaisuarvo",
|
||||
"complete_title": "Suorita: ",
|
||||
"checklist": "Tarkistuslista",
|
||||
"checklist_steps_optional": "Tarkistuslistan vaiheet (valinnainen)",
|
||||
"checklist_placeholder": "Puhdista suodatin\nVaihda tiiviste\nTarkista paine",
|
||||
"checklist_help": "Yksi vaihe riviä kohden. Enintään 100 kohdetta.",
|
||||
"err_too_long": "{field}: liian pitkä (enint. {n} merkkiä)",
|
||||
"err_too_short": "{field}: liian lyhyt (väh. {n} merkkiä)",
|
||||
"err_value_too_high": "{field}: liian suuri (enint. {n})",
|
||||
"err_value_too_low": "{field}: liian pieni (väh. {n})",
|
||||
"err_required": "{field}: pakollinen",
|
||||
"err_wrong_type": "{field}: väärä tyyppi (odotettiin: {type})",
|
||||
"err_invalid_choice": "{field}: ei sallittu arvo",
|
||||
"err_invalid_value": "{field}: virheellinen arvo",
|
||||
"feat_schedule_time": "Kellonajan ajoitus",
|
||||
"feat_schedule_time_desc": "Tehtävät erääntyvät tiettyyn kellonaikaan keskiyön sijaan.",
|
||||
"schedule_time_optional": "Erääntyy klo (valinnainen, HH:MM)",
|
||||
"schedule_time_help": "Tyhjä = keskiyö (oletus). HA:n aikavyöhyke.",
|
||||
"at_time": "klo",
|
||||
"notes_optional": "Muistiinpanot (valinnainen)",
|
||||
"cost_optional": "Kustannus (valinnainen)",
|
||||
"duration_minutes": "Kesto minuutteina (valinnainen)",
|
||||
"days": "päivää",
|
||||
"day": "päivä",
|
||||
"today": "Tänään",
|
||||
"d_overdue": "pv myöhässä",
|
||||
"no_tasks": "Ei vielä huoltotehtäviä. Luo kohde aloittaaksesi.",
|
||||
"no_tasks_short": "Ei tehtäviä",
|
||||
"no_history": "Ei vielä historiatietoja.",
|
||||
"show_all": "Näytä kaikki",
|
||||
"cost_duration_chart": "Kustannus ja kesto",
|
||||
"installed": "Asennettu",
|
||||
"confirm_delete_object": "Poistetaanko tämä kohde ja kaikki sen tehtävät?",
|
||||
"confirm_delete_task": "Poistetaanko tämä tehtävä?",
|
||||
"min": "Min",
|
||||
"max": "Maks",
|
||||
"save": "Tallenna",
|
||||
"saving": "Tallennetaan…",
|
||||
"edit_task": "Muokkaa tehtävää",
|
||||
"new_task": "Uusi huoltotehtävä",
|
||||
"task_name": "Tehtävän nimi",
|
||||
"maintenance_type": "Huoltotyyppi",
|
||||
"priority": "Prioriteetti",
|
||||
"labels": "Labels",
|
||||
"labels_placeholder": "e.g. safety, seasonal, tenant-visible",
|
||||
"labels_help": "Comma-separated tags for filtering and reporting.",
|
||||
"priority_low": "Matala",
|
||||
"priority_normal": "Normaali",
|
||||
"priority_high": "Korkea",
|
||||
"schedule_type": "Ajoitustyyppi",
|
||||
"interval_days": "Väli (päivää)",
|
||||
"warning_days": "Varoituspäivät",
|
||||
"earliest_completion_days": "Earliest completion (days before due)",
|
||||
"earliest_completion_days_help": "Leave empty to allow completing any time. 0 = only on/after the due date.",
|
||||
"last_performed_optional": "Viimeksi suoritettu (valinnainen)",
|
||||
"interval_anchor": "Välin ankkuri",
|
||||
"anchor_completion": "Suorituspäivästä",
|
||||
"anchor_planned": "Suunnitellusta päivästä (ei poikkeamaa)",
|
||||
"edit_object": "Muokkaa kohdetta",
|
||||
"name": "Nimi",
|
||||
"manufacturer_optional": "Valmistaja (valinnainen)",
|
||||
"model_optional": "Malli (valinnainen)",
|
||||
"serial_number_optional": "Sarjanumero (valinnainen)",
|
||||
"serial_number_label": "S/N",
|
||||
"documentation_url_label": "Käyttöohje",
|
||||
"object_notes_label": "Muistiinpanot",
|
||||
"sort_due_date": "Eräpäivä",
|
||||
"sort_object": "Kohteen nimi",
|
||||
"sort_type": "Tyyppi",
|
||||
"sort_task_name": "Tehtävän nimi",
|
||||
"all_objects": "Kaikki kohteet",
|
||||
"tasks_lower": "tehtävää",
|
||||
"no_tasks_yet": "Ei vielä tehtäviä",
|
||||
"add_first_task": "Lisää ensimmäinen tehtävä",
|
||||
"trigger_configuration": "Laukaisimen asetukset",
|
||||
"entity_id": "Entiteetin tunnus",
|
||||
"comma_separated": "pilkuilla eroteltu",
|
||||
"entity_logic": "Entiteettilogiikka",
|
||||
"entity_logic_any": "Mikä tahansa entiteetti laukaisee",
|
||||
"entity_logic_all": "Kaikkien entiteettien on laukaistava",
|
||||
"entities": "entiteettiä",
|
||||
"attribute_optional": "Attribuutti (valinnainen, tyhjä = tila)",
|
||||
"use_entity_state": "Käytä entiteetin tilaa (ei attribuuttia)",
|
||||
"trigger_above": "Laukaise yli",
|
||||
"trigger_below": "Laukaise alle",
|
||||
"for_at_least_minutes": "Vähintään (minuuttia)",
|
||||
"safety_interval_days": "Turvaväli (päivää, valinnainen)",
|
||||
"safety_interval": "Turvaväli (valinnainen)",
|
||||
"delta_mode": "Delta-tila",
|
||||
"from_state_optional": "Lähtötilasta (valinnainen)",
|
||||
"to_state_optional": "Kohdetilaan (valinnainen)",
|
||||
"documentation_url_optional": "Dokumentaation URL (valinnainen)",
|
||||
"object_notes_optional": "Muistiinpanot (valinnainen)",
|
||||
"nfc_tag_id_optional": "NFC-tunnisteen tunnus (valinnainen)",
|
||||
"nfc_tags_empty_help": "Home Assistantiin ei ole vielä rekisteröity NFC-tunnisteita.",
|
||||
"nfc_tags_open_settings": "Avaa tunnisteiden asetukset",
|
||||
"nfc_tags_refresh": "Päivitä",
|
||||
"environmental_entity_optional": "Ympäristöanturi (valinnainen)",
|
||||
"environmental_entity_helper": "esim. sensor.outdoor_temperature — säätää väliä ympäristöolosuhteiden mukaan",
|
||||
"environmental_attribute_optional": "Ympäristöattribuutti (valinnainen)",
|
||||
"nfc_tag_id": "NFC-tunnisteen tunnus",
|
||||
"nfc_linked": "NFC-tunniste linkitetty",
|
||||
"nfc_link_hint": "Linkitä NFC-tunniste napsauttamalla",
|
||||
"responsible_user": "Vastuukäyttäjä",
|
||||
"shared_with": "Shared with (rotation)",
|
||||
"shared_with_help": "Pick multiple people to share this task; the responsible person rotates on each completion.",
|
||||
"rotation_strategy": "Rotation",
|
||||
"rotation_none": "No rotation",
|
||||
"rotation_round_robin": "Round-robin",
|
||||
"rotation_least_completed": "Least completed",
|
||||
"rotation_random": "Random",
|
||||
"no_user_assigned": "(Ei käyttäjää määritetty)",
|
||||
"all_users": "Kaikki käyttäjät",
|
||||
"my_tasks": "Omat tehtävät",
|
||||
"tab_calendar": "Kalenteri",
|
||||
"cal_no_events": "Ei huoltoja",
|
||||
"cal_window_7": "7 päivää",
|
||||
"cal_window_14": "14 päivää",
|
||||
"cal_window_30": "30 päivää",
|
||||
"cal_window_365": "1 vuosi",
|
||||
"cal_every_n_days": "{n} päivän välein",
|
||||
"cal_source_time": "Aikaperusteinen",
|
||||
"cal_source_time_adaptive": "Aikaperusteinen (mukautuva)",
|
||||
"cal_source_sensor": "Anturiperusteinen",
|
||||
"cal_predicted": "ennustettu",
|
||||
"cal_confidence_high": "korkea luottamus",
|
||||
"cal_confidence_medium": "keskitason luottamus",
|
||||
"cal_confidence_low": "matala luottamus",
|
||||
"budget_monthly": "Kuukausibudjetti",
|
||||
"budget_yearly": "Vuosibudjetti",
|
||||
"groups": "Ryhmät",
|
||||
"new_group": "Uusi ryhmä",
|
||||
"edit_group": "Muokkaa ryhmää",
|
||||
"no_groups": "Ei vielä ryhmiä",
|
||||
"delete_group": "Poista ryhmä",
|
||||
"delete_group_confirm": "Poistetaanko ryhmä '{name}'?",
|
||||
"group_select_tasks": "Valitse tehtävät",
|
||||
"group_name_required": "Nimi on pakollinen",
|
||||
"description_optional": "Kuvaus (valinnainen)",
|
||||
"selected": "Valittu",
|
||||
"loading_chart": "Ladataan kaaviotietoja...",
|
||||
"hide_outliers": "Piilota poikkeamat (anturivirheet)",
|
||||
"was_maintenance_needed": "Oliko tämä huolto tarpeen?",
|
||||
"feedback_needed": "Tarpeen",
|
||||
"feedback_not_needed": "Ei tarpeen",
|
||||
"feedback_not_sure": "Ei varma",
|
||||
"suggested_interval": "Ehdotettu väli",
|
||||
"apply_suggestion": "Käytä",
|
||||
"reanalyze": "Analysoi uudelleen",
|
||||
"reanalyze_result": "Uusi analyysi",
|
||||
"reanalyze_insufficient_data": "Ei tarpeeksi tietoja suosituksen tekemiseen",
|
||||
"data_points": "tietopistettä",
|
||||
"dismiss_suggestion": "Hylkää",
|
||||
"confidence_low": "Matala",
|
||||
"confidence_medium": "Keskitaso",
|
||||
"confidence_high": "Korkea",
|
||||
"recommended": "suositeltu",
|
||||
"seasonal_awareness": "Kausivaihtelun huomiointi",
|
||||
"edit_seasonal_overrides": "Muokkaa kausikertoimia",
|
||||
"seasonal_overrides_title": "Kausikertoimet (ohitus)",
|
||||
"seasonal_overrides_hint": "Kerroin kuukautta kohden (0,1–5,0). Tyhjä = opitaan automaattisesti.",
|
||||
"seasonal_override_invalid": "Virheellinen arvo",
|
||||
"seasonal_override_range": "Kertoimen on oltava välillä 0,1–5,0",
|
||||
"clear_all": "Tyhjennä kaikki",
|
||||
"seasonal_chart_title": "Kausikertoimet",
|
||||
"seasonal_learned": "Opittu",
|
||||
"seasonal_manual": "Manuaalinen",
|
||||
"month_jan": "Tammi",
|
||||
"month_feb": "Helmi",
|
||||
"month_mar": "Maalis",
|
||||
"month_apr": "Huhti",
|
||||
"month_may": "Touko",
|
||||
"month_jun": "Kesä",
|
||||
"month_jul": "Heinä",
|
||||
"month_aug": "Elo",
|
||||
"month_sep": "Syys",
|
||||
"month_oct": "Loka",
|
||||
"month_nov": "Marras",
|
||||
"month_dec": "Joulu",
|
||||
"sensor_prediction": "Anturiennuste",
|
||||
"degradation_trend": "Trendi",
|
||||
"trend_rising": "Nouseva",
|
||||
"trend_falling": "Laskeva",
|
||||
"trend_stable": "Vakaa",
|
||||
"trend_insufficient_data": "Riittämättömät tiedot",
|
||||
"days_until_threshold": "Päiviä kynnysarvoon",
|
||||
"threshold_exceeded": "Kynnysarvo ylitetty",
|
||||
"environmental_adjustment": "Ympäristökerroin",
|
||||
"sensor_prediction_urgency": "Anturi ennustaa kynnysarvon noin {days} päivässä",
|
||||
"day_short": "pv",
|
||||
"weibull_reliability_curve": "Luotettavuuskäyrä",
|
||||
"weibull_failure_probability": "Vikaantumistodennäköisyys",
|
||||
"weibull_r_squared": "Sovitus R²",
|
||||
"beta_early_failures": "Varhaiset viat",
|
||||
"beta_random_failures": "Satunnaiset viat",
|
||||
"beta_wear_out": "Kuluminen",
|
||||
"beta_highly_predictable": "Erittäin ennustettava",
|
||||
"confidence_interval": "Luottamusväli",
|
||||
"confidence_conservative": "Varovainen",
|
||||
"confidence_aggressive": "Optimistinen",
|
||||
"current_interval_marker": "Nykyinen väli",
|
||||
"recommended_marker": "Suositeltu",
|
||||
"characteristic_life": "Ominaiselinikä",
|
||||
"chart_mini_sparkline": "Trendikäyrä",
|
||||
"chart_history": "Kustannus- ja kestohistoria",
|
||||
"chart_seasonal": "Kausikertoimet, 12 kuukautta",
|
||||
"chart_weibull": "Weibull-luotettavuuskäyrä",
|
||||
"chart_sparkline": "Anturin laukaisuarvon kaavio",
|
||||
"days_progress": "Päivien edistyminen",
|
||||
"qr_code": "QR-koodi",
|
||||
"qr_generating": "Luodaan QR-koodia…",
|
||||
"qr_error": "QR-koodin luonti epäonnistui.",
|
||||
"qr_error_no_url": "HA:n URL-osoitetta ei ole määritetty. Aseta ulkoinen tai sisäinen URL kohdassa Asetukset → Järjestelmä → Verkko.",
|
||||
"save_error": "Tallennus epäonnistui. Yritä uudelleen.",
|
||||
"qr_print": "Tulosta",
|
||||
"qr_download": "Lataa SVG",
|
||||
"qr_action": "Toiminto skannattaessa",
|
||||
"qr_action_view": "Näytä huoltotiedot",
|
||||
"qr_action_complete": "Merkitse huolto valmiiksi",
|
||||
"qr_url_mode": "Linkin tyyppi",
|
||||
"qr_mode_companion": "Companion-sovellus",
|
||||
"qr_mode_local": "Paikallinen (mDNS)",
|
||||
"qr_mode_server": "Palvelimen URL",
|
||||
"overview": "Yleiskatsaus",
|
||||
"analysis": "Analyysi",
|
||||
"recent_activities": "Viimeaikaiset toimet",
|
||||
"search_notes": "Hae muistiinpanoja",
|
||||
"avg_cost": "Keskim. kustannus",
|
||||
"no_advanced_features": "Lisäominaisuuksia ei ole otettu käyttöön",
|
||||
"no_advanced_features_hint": "Ota käyttöön ”Mukautuvat välit” tai ”Kausivaihtelut” integraation asetuksissa nähdäksesi analyysitiedot täällä.",
|
||||
"analysis_not_enough_data": "Ei vielä tarpeeksi tietoja analyysiin.",
|
||||
"analysis_not_enough_data_hint": "Weibull-analyysi vaatii vähintään 5 suoritettua huoltoa; kausivaihtelut näkyvät kun kuukautta kohden on yli 6 tietopistettä.",
|
||||
"analysis_manual_task_hint": "Manuaaliset tehtävät ilman väliä eivät tuota analyysitietoja.",
|
||||
"completions": "suoritusta",
|
||||
"current": "Nykyinen",
|
||||
"shorter": "Lyhyempi",
|
||||
"longer": "Pidempi",
|
||||
"normal": "Normaali",
|
||||
"disabled": "Pois käytöstä",
|
||||
"compound_logic": "Yhdistelmälogiikka",
|
||||
"compound": "Yhdistetty (useita ehtoja)",
|
||||
"compound_logic_and": "JA — kaikkien ehtojen on täytyttävä",
|
||||
"compound_logic_or": "TAI — mikä tahansa ehto riittää",
|
||||
"compound_help": "Yhdistä useita anturiehtoja yhdeksi laukaisimeksi.",
|
||||
"compound_no_conditions": "Ei vielä ehtoja — lisää vähintään yksi.",
|
||||
"compound_add_condition": "Lisää ehto",
|
||||
"compound_condition": "Ehto",
|
||||
"compound_remove_condition": "Poista ehto",
|
||||
"card_title": "Otsikko",
|
||||
"card_show_header": "Näytä otsikko tilastoineen",
|
||||
"card_show_actions": "Näytä toimintopainikkeet",
|
||||
"card_compact": "Tiivis tila",
|
||||
"card_max_items": "Enimmäismäärä (0 = kaikki)",
|
||||
"card_filter_status": "Suodata tilan mukaan",
|
||||
"card_filter_status_help": "Tyhjä = näytä kaikki tilat.",
|
||||
"card_filter_objects": "Suodata kohteiden mukaan",
|
||||
"card_filter_objects_help": "Tyhjä = näytä kaikki kohteet.",
|
||||
"card_filter_entities": "Suodata entiteettien mukaan (entity_id)",
|
||||
"card_filter_entities_help": "Valitse sensor- / binary_sensor-entiteetit tästä integraatiosta. Tyhjä = kaikki.",
|
||||
"card_loading_objects": "Ladataan kohteita…",
|
||||
"card_load_error": "Kohteita ei voitu ladata — tarkista WebSocket-yhteys.",
|
||||
"card_no_tasks_title": "Ei vielä huoltotehtäviä",
|
||||
"card_no_tasks_cta": "→ Luo sellainen Huolto-paneelissa",
|
||||
"no_objects": "Ei vielä kohteita.",
|
||||
"action_error": "Toiminto epäonnistui. Yritä uudelleen.",
|
||||
"area_id_optional": "Alue (valinnainen)",
|
||||
"installation_date_optional": "Asennuspäivä (valinnainen)",
|
||||
"warranty_expiry_optional": "Takuun päättyminen (valinnainen)",
|
||||
"warranty": "Takuu",
|
||||
"warranty_valid_until": "voimassa {date} asti",
|
||||
"warranty_expires_in": "päättyy {days} päivän kuluttua",
|
||||
"warranty_expired": "päättynyt",
|
||||
"cal_past_windows": "Menneet jaksot",
|
||||
"cal_forward_windows": "Tulevat jaksot",
|
||||
"history_edit_title": "Muokkaa historiamerkintää",
|
||||
"history_edit_timestamp": "Aikaleima",
|
||||
"manufacturer": "Valmistaja",
|
||||
"model": "Malli",
|
||||
"area": "Alue",
|
||||
"actions": "Toiminnot",
|
||||
"view_mode_label": "Näkymä",
|
||||
"view_cards": "Korttinäkymä",
|
||||
"view_table": "Taulukkonäkymä",
|
||||
"objects_table_columns_label": "Kohdetaulukon sarakkeet",
|
||||
"objects_table_columns_hint": "Valitse, mitkä sarakkeet näkyvät kohdetaulukkonäkymässä.",
|
||||
"custom_icon_optional": "Kuvake (valinnainen, esim. mdi:wrench)",
|
||||
"task_enabled": "Tehtävä käytössä",
|
||||
"skip_reason_prompt": "Ohitetaanko tämä tehtävä?",
|
||||
"reason_optional": "Syy (valinnainen)",
|
||||
"reset_date_prompt": "Merkitäänkö tehtävä suoritetuksi?",
|
||||
"reset_date_optional": "Viimeksi suoritettu -päivä (valinnainen, oletus tänään)",
|
||||
"notes_label": "Muistiinpanot",
|
||||
"documentation_label": "Dokumentaatio",
|
||||
"no_nfc_tag": "— Ei tunnistetta —",
|
||||
"dashboard": "Kojelauta",
|
||||
"tab_today": "Tänään",
|
||||
"palette_placeholder": "Hae kohteita ja tehtäviä…",
|
||||
"palette_no_results": "Ei osumia",
|
||||
"palette_hint": "↑↓ liiku · Enter avaa · Esc sulje",
|
||||
"today_all_caught_up": "Kaikki hoidettu! Tällä viikolla ei ole mitään.",
|
||||
"today_overdue": "Myöhässä",
|
||||
"today_due_today": "Erääntyy tänään",
|
||||
"today_this_week": "Tällä viikolla",
|
||||
"settings": "Asetukset",
|
||||
"settings_features": "Lisäominaisuudet",
|
||||
"settings_features_desc": "Ota lisäominaisuudet käyttöön tai pois käytöstä. Poistaminen piilottaa ne käyttöliittymästä mutta ei poista tietoja.",
|
||||
"feat_adaptive": "Mukautuva ajoitus",
|
||||
"feat_adaptive_desc": "Opi optimaaliset välit huoltohistoriasta",
|
||||
"feat_predictions": "Anturiennusteet",
|
||||
"feat_predictions_desc": "Ennusta laukaisupäivät anturin kulumisesta",
|
||||
"feat_seasonal": "Kausisäädöt",
|
||||
"feat_seasonal_desc": "Säädä välejä kausivaihteluiden mukaan",
|
||||
"feat_environmental": "Ympäristökorrelaatio",
|
||||
"feat_environmental_desc": "Korreloi välit lämpötilan/kosteuden kanssa",
|
||||
"feat_budget": "Budjettiseuranta",
|
||||
"feat_budget_desc": "Seuraa kuukausittaisia ja vuosittaisia huoltokuluja",
|
||||
"feat_groups": "Tehtäväryhmät",
|
||||
"feat_groups_desc": "Järjestä tehtävät loogisiin ryhmiin",
|
||||
"feat_checklists": "Tarkistuslistat",
|
||||
"feat_checklists_desc": "Monivaiheiset menettelyt tehtävän suorittamiseen",
|
||||
"settings_general": "Yleiset",
|
||||
"settings_default_warning": "Oletusvaroituspäivät",
|
||||
"settings_panel_enabled": "Sivupalkin paneeli",
|
||||
"settings_panel_title": "Sivupalkin paneelin otsikko",
|
||||
"settings_notifications": "Ilmoitukset",
|
||||
"settings_notify_service": "Ilmoituspalvelu",
|
||||
"test_notification": "Testi-ilmoitus",
|
||||
"send_test": "Lähetä testi",
|
||||
"testing": "Lähetetään…",
|
||||
"test_notification_success": "Testi-ilmoitus lähetetty",
|
||||
"test_notification_failed": "Testi-ilmoitus epäonnistui",
|
||||
"settings_notify_due_soon": "Ilmoita, kun erääntyy pian",
|
||||
"settings_notify_overdue": "Ilmoita, kun myöhässä",
|
||||
"settings_notify_triggered": "Ilmoita, kun laukaistu",
|
||||
"settings_interval_hours": "Toistoväli (tuntia, 0 = kerran)",
|
||||
"settings_quiet_hours": "Hiljaiset tunnit",
|
||||
"settings_quiet_start": "Alku",
|
||||
"settings_quiet_end": "Loppu",
|
||||
"settings_max_per_day": "Ilmoituksia enintään päivässä (0 = rajoittamaton)",
|
||||
"settings_bundling": "Niputa ilmoitukset",
|
||||
"settings_bundle_threshold": "Niputuksen kynnys",
|
||||
"settings_reminder_leads": "Extra reminders (days before due)",
|
||||
"settings_reminder_leads_hint": "Comma-separated lead times, e.g. 14, 3, 0 — one extra reminder fires on each matching day. Empty = off.",
|
||||
"settings_actions": "Mobiilitoimintopainikkeet",
|
||||
"settings_action_complete": "Näytä Valmis-painike",
|
||||
"settings_action_skip": "Näytä Ohita-painike",
|
||||
"settings_action_snooze": "Näytä Torkku-painike",
|
||||
"settings_weekly_digest": "Viikkokooste",
|
||||
"settings_weekly_digest_hint": "Yksi kooste-ilmoitus maanantaiaamuna, kun tehtäviä on.",
|
||||
"settings_warranty_reminder": "Warranty expiry reminder",
|
||||
"settings_warranty_reminder_days": "Days before expiry",
|
||||
"settings_warranty_reminder_hint": "Notify once when an object's warranty is this many days from expiring.",
|
||||
"settings_snooze_hours": "Torkun kesto (tuntia)",
|
||||
"settings_budget": "Budjetti",
|
||||
"settings_currency": "Valuutta",
|
||||
"settings_budget_monthly": "Kuukausibudjetti",
|
||||
"settings_budget_yearly": "Vuosibudjetti",
|
||||
"settings_budget_alerts": "Budjettihälytykset",
|
||||
"settings_budget_threshold": "Hälytyskynnys (%)",
|
||||
"settings_import_export": "Tuonti / Vienti",
|
||||
"settings_export_json": "Vie JSON",
|
||||
"settings_export_yaml": "Vie YAML",
|
||||
"settings_export_csv": "Vie CSV",
|
||||
"settings_import_csv": "Tuo CSV",
|
||||
"settings_import_placeholder": "Liitä JSON- tai CSV-sisältö tähän…",
|
||||
"settings_import_btn": "Tuo",
|
||||
"settings_import_success": "{count} kohdetta tuotu onnistuneesti.",
|
||||
"settings_export_success": "Vienti ladattu.",
|
||||
"settings_saved": "Asetus tallennettu.",
|
||||
"settings_include_history": "Sisällytä historia",
|
||||
"sort_alphabetical": "Aakkosjärjestys",
|
||||
"sort_due_soonest": "Erääntyy pian",
|
||||
"sort_task_count": "Tehtävien määrä",
|
||||
"sort_area": "Alue",
|
||||
"sort_assigned_user": "Määritetty käyttäjä",
|
||||
"sort_group": "Ryhmä",
|
||||
"groupby_none": "Ei ryhmittelyä",
|
||||
"groupby_area": "Alueen mukaan",
|
||||
"groupby_group": "Ryhmän mukaan",
|
||||
"groupby_user": "Käyttäjän mukaan",
|
||||
"filter_label": "Suodata",
|
||||
"user_label": "Käyttäjä",
|
||||
"sort_label": "Lajittele",
|
||||
"group_by_label": "Ryhmittele",
|
||||
"state_value_help": "Käytä HA:n tila-arvoa (yleensä pienillä kirjaimilla, esim. \"on\"/\"off\"). Kirjainkoko normalisoidaan tallennettaessa.",
|
||||
"target_changes_help": "Vastaavien siirtymien määrä ennen laukaisua (oletus: 1).",
|
||||
"qr_print_title": "Tulosta QR-koodit",
|
||||
"qr_print_desc": "Luo tulostettava sivu QR-koodeja, jotka voit leikata ja kiinnittää laitteisiin.",
|
||||
"qr_print_load": "Lataa kohteet",
|
||||
"qr_print_filter": "Suodata",
|
||||
"qr_print_objects": "Kohteet",
|
||||
"qr_print_actions": "Toiminnot",
|
||||
"qr_print_url_mode": "Linkin tyyppi",
|
||||
"qr_print_estimate": "Arvioitu QR-koodien määrä",
|
||||
"qr_print_over_limit": "raja on 200, rajaa suodatinta",
|
||||
"qr_print_generate": "Luo QR-koodit",
|
||||
"qr_print_generating": "Luodaan…",
|
||||
"qr_print_ready": "QR-koodit valmiina",
|
||||
"qr_print_print_button": "Tulosta",
|
||||
"qr_print_empty": "Ei mitään luotavaa",
|
||||
"qr_action_skip": "Ohita",
|
||||
"vacation_title": "Lomatila",
|
||||
"vacation_active": "aktiivinen",
|
||||
"vacation_ended": "päättynyt",
|
||||
"vacation_desc": "Suunnittele loma: ilmoitukset keskeytetään jakson ajaksi plus puskuripäivien ajaksi. Voit ottaa tietyt tehtävät takaisin käyttöön.",
|
||||
"vacation_enable": "Ota lomatila käyttöön",
|
||||
"vacation_start": "Alku",
|
||||
"vacation_end": "Loppu",
|
||||
"vacation_buffer": "Puskuri (päivää)",
|
||||
"vacation_exempt_title": "Ilmoita silti loman aikana",
|
||||
"vacation_exempt_desc": "Valitse tehtävät, joista ilmoitetaan silti loman aikana (esim. kriittinen altaan kemia).",
|
||||
"vacation_load_tasks": "Lataa tehtävät",
|
||||
"vacation_preview_btn": "Näytä esikatselu",
|
||||
"vacation_preview_affected": "tehtävää koskee",
|
||||
"vacation_event_due_soon": "erääntyy pian",
|
||||
"vacation_event_overdue": "menee myöhässä",
|
||||
"vacation_event_triggered_est": "anturin laukaisu mahdollinen",
|
||||
"vacation_sensor_based": "(anturiperusteinen)",
|
||||
"vacation_action_notify": "Ilmoita silti",
|
||||
"vacation_action_unsilence": "Vaimenna uudelleen",
|
||||
"vacation_marked_complete": "Merkitty valmiiksi",
|
||||
"vacation_marked_skip": "Ohitettu",
|
||||
"vacation_end_now": "Lopeta loma nyt",
|
||||
"add": "Lisää",
|
||||
"show_stats": "Näytä tilastot ja kaaviot",
|
||||
"hide_stats": "Piilota tilastot",
|
||||
"adaptive_no_data": "Ei vielä tarpeeksi suoritushistoriaa mukautuvaan analyysiin. Suorita tämä tehtävä muutaman kerran lisää avataksesi väliehdotukset ja luotettavuuskaaviot.",
|
||||
"suggestion_applied": "Ehdotettu väli otettu käyttöön",
|
||||
"vacation_mode": "Lomatila",
|
||||
"vacation_status_active": "Aktiivinen nyt",
|
||||
"vacation_status_scheduled": "Ajoitettu",
|
||||
"vacation_status_inactive": "Ei aktiivinen",
|
||||
"vacation_end_now_confirm": "Lopetetaanko loma heti?",
|
||||
"vacation_exempt_count": "poikkeusta",
|
||||
"vacation_advanced": "Lisäasetukset…",
|
||||
"vacation_open_panel": "Avaa paneelissa",
|
||||
"enable": "Ota käyttöön",
|
||||
"saved": "Tallennettu",
|
||||
"budget_monthly_set": "Aseta kuukausittain",
|
||||
"budget_yearly_set": "Aseta vuosittain",
|
||||
"budget_advanced": "Valuutta, hälytykset…",
|
||||
"budget_open_panel": "Avaa paneelissa",
|
||||
"groups_empty": "Ei vielä ryhmiä.",
|
||||
"group_new_placeholder": "Lisää ryhmä…",
|
||||
"group_delete_confirm": "Poistetaanko ryhmä \"{name}\"?",
|
||||
"groups_manage_tasks": "Hallinnoi tehtävien määrityksiä…",
|
||||
"groups_open_panel": "Avaa paneelissa",
|
||||
"unassigned": "Ei määritetty",
|
||||
"no_area": "Ei aluetta",
|
||||
"has_overdue": "Sisältää myöhässä olevia tehtäviä",
|
||||
"object": "Kohde",
|
||||
"settings_panel_access": "Paneelin käyttöoikeus",
|
||||
"settings_panel_access_desc": "Ylläpitäjillä on aina täydet oikeudet. Delegoidaksesi luonnin, muokkauksen ja poiston tietyille ei-ylläpitäjille, kytke tämä päälle ja valitse heidät alta — muut näkevät vain Valmis ja Ohita.",
|
||||
"settings_operator_write": "Salli valittujen käyttäjien luoda, muokata ja poistaa",
|
||||
"settings_operator_write_desc": "Pois: vain ylläpitäjät voivat muuttaa sisältöä. Päällä: myös alla valitut käyttäjät saavat täydet oikeudet.",
|
||||
"no_non_admin_users": "Ei-ylläpitäjäkäyttäjiä ei löytynyt. Lisää heitä kohdassa Asetukset → Henkilöt.",
|
||||
"owner_label": "Omistaja",
|
||||
"feat_completion_actions": "Suoritustoiminnot",
|
||||
"feat_completion_actions_desc": "Tehtäväkohtainen HA-toiminto suorituksen yhteydessä + pikasuoritus-QR esiasetetuilla arvoilla.",
|
||||
"on_complete_action_title": "Suorituksen yhteydessä: laukaise HA-toiminto (valinnainen)",
|
||||
"on_complete_action_desc": "Kutsuu HA-palvelua, kun tehtävä suoritetaan — esim. nollaa laitteen laskuri.",
|
||||
"on_complete_action_service": "Palvelu",
|
||||
"on_complete_action_target": "Kohde-entiteetti",
|
||||
"on_complete_action_target_hint": "Huom: entiteetin toimialueen on vastattava palvelua — esim. 'button.press' toimii vain button.*-entiteeteillä, 'counter.increment' vain counter.*-entiteeteillä, 'input_button.press' vain input_button.*-entiteeteillä jne. Epäsopivuuden sattuessa toiminto epäonnistuu hiljaisesti (HA kirjaa 'Referenced entities ... missing or not currently available').",
|
||||
"on_complete_action_data": "Tiedot (JSON, valinnainen)",
|
||||
"on_complete_action_test": "Tarkista asetukset",
|
||||
"on_complete_action_test_success": "✓ Asetukset kelvolliset (toiminto laukeaa vain tehtävän suorituksen yhteydessä)",
|
||||
"on_complete_action_test_failed": "Epäonnistui",
|
||||
"quick_complete_defaults_title": "Pikasuorituksen oletukset (QR-skannauksille, valinnainen)",
|
||||
"quick_complete_defaults_desc": "Esiasetetut arvot pikasuoritus-QR-skannauksille. Ilman näitä QR avaa suoritusikkunan.",
|
||||
"quick_complete_defaults_notes": "Muistiinpanot",
|
||||
"quick_complete_defaults_cost": "Kustannus",
|
||||
"quick_complete_defaults_duration": "Kesto (minuuttia)",
|
||||
"quick_complete_defaults_feedback_none": "Ei palautetta",
|
||||
"quick_complete_defaults_feedback_needed": "Oli tarpeen",
|
||||
"quick_complete_defaults_feedback_not_needed": "Ei tarpeen",
|
||||
"quick_complete_success": "Merkitty nopeasti valmiiksi",
|
||||
"show_all_objects": "Näytä kaikki kohteet",
|
||||
"show_all_tasks": "Tyhjennä suodatin — näytä kaikki tehtävät",
|
||||
"filter_to_overdue": "Suodata tehtävälista vain myöhässä oleviin",
|
||||
"filter_to_due_soon": "Suodata tehtävälista vain pian erääntyviin",
|
||||
"filter_to_triggered": "Suodata tehtävälista vain laukaistuihin",
|
||||
"open_task": "Avaa tehtävä",
|
||||
"show_details": "Näytä historia ja tilastot",
|
||||
"hide_details": "Piilota tiedot",
|
||||
"history_empty": "Ei vielä historiaa.",
|
||||
"history_edit_button": "Muokkaa merkintää",
|
||||
"total_cost": "Kokonaiskustannus",
|
||||
"times_performed": "Suoritettu",
|
||||
"older_entries": "vanhempaa",
|
||||
"open_in_panel": "Avaa Huolto-paneelissa",
|
||||
"skip_reason": "Ohituksen syy (valinnainen)",
|
||||
"reset_to_date": "Nollaa viimeksi suoritettu -päivä",
|
||||
"delete_task_confirm": "Poistetaanko tämä tehtävä ja sen historia?",
|
||||
"delete_object_confirm": "Poistetaanko tämä kohde ja kaikki sen tehtävät?",
|
||||
"loading": "Ladataan…",
|
||||
"archive": "Arkistoi",
|
||||
"undo": "Kumoa",
|
||||
"task_archived": "Tehtävä arkistoitu",
|
||||
"object_archived": "Kohde arkistoitu",
|
||||
"unarchive": "Palauta arkistosta",
|
||||
"archived": "Arkistoitu",
|
||||
"show_archived": "Näytä arkistoidut",
|
||||
"hide_archived": "Piilota arkistoidut",
|
||||
"confirm_archive_object": "Arkistoidaanko tämä kohde ja sen tehtävät? Historia säilyy ja sen voi palauttaa myöhemmin.",
|
||||
"settings_archive": "Arkistointi ja säilytys",
|
||||
"settings_archive_desc": "Siirrä valmiit kertatehtävät arkistoon poistamatta niitä. Arkistoidut kohteet ovat piilossa ja passiivisia, mutta säilyttävät historian ja kustannukset.",
|
||||
"settings_archive_oneoff_days": "Arkistoi valmiit kertatehtävät automaattisesti (päivää, 0 = pois)",
|
||||
"settings_delete_archived_oneoff_days": "Poista arkistoidut kertatehtävät automaattisesti (päivää, 0 = ei koskaan)",
|
||||
"archive_object": "Arkistoi kohde",
|
||||
"unarchive_object": "Palauta kohde arkistosta",
|
||||
"documents": "Asiakirjat",
|
||||
"documents_empty": "Ei vielä asiakirjoja.",
|
||||
"doc_upload": "Lataa tiedosto",
|
||||
"doc_uploading": "Ladataan…",
|
||||
"doc_add_link": "Lisää linkki",
|
||||
"doc_link_url": "URL (https://…)",
|
||||
"doc_link_title": "Otsikko (valinnainen)",
|
||||
"doc_open": "Avaa",
|
||||
"doc_delete_confirm": "Poistetaanko \"{name}\"?",
|
||||
"doc_too_large": "Tiedosto on liian suuri (enint. 25 Mt).",
|
||||
"doc_upload_failed": "Lataus epäonnistui.",
|
||||
"completion_photo_optional": "Completion photo (optional)",
|
||||
"add_photo": "Add photo",
|
||||
"uploading": "Uploading…",
|
||||
"remove": "Remove",
|
||||
"doc_deduped": "Jo tallennettu muualle — jaettu, ei lisätilaa.",
|
||||
"doc_dup_in_object": "Tämä tiedosto on jo liitetty tähän kohteeseen.",
|
||||
"doc_link_invalid": "Vain http/https-linkit ovat sallittuja.",
|
||||
"doc_cat_manual": "Käyttöohje",
|
||||
"doc_cat_warranty": "Takuu",
|
||||
"doc_cat_invoice": "Lasku",
|
||||
"doc_cat_spare_parts": "Varaosat",
|
||||
"doc_cat_photo": "Valokuva",
|
||||
"doc_cat_other": "Muu",
|
||||
"doc_link_badge": "Linkki",
|
||||
"doc_storage_title": "Asiakirjojen tallennustila",
|
||||
"doc_storage_saved": "Säästetty deduplikoinnilla",
|
||||
"doc_storage_refresh": "Päivitä",
|
||||
"doc_download": "Lataa",
|
||||
"doc_close": "Sulje",
|
||||
"doc_camera": "Ota kuva",
|
||||
"doc_drop_hint": "Pudota tiedostot tähän",
|
||||
"doc_task_none": "Tähän tehtävään ei ole liitetty asiakirjoja.",
|
||||
"doc_link_existing": "Liitä asiakirja…",
|
||||
"doc_attach": "Liitä",
|
||||
"doc_unlink": "Poista liitos",
|
||||
"doc_page": "Sivu",
|
||||
"chart_range_7d": "7pv",
|
||||
"chart_range_30d": "30pv",
|
||||
"chart_range_90d": "90pv",
|
||||
"chart_range_1y": "1v",
|
||||
"chart_since_service": "edellisen huollon jälkeen",
|
||||
"chart_no_stats": "Tälle entiteetille ei ole pitkän aikavälin tilastoja — näytetään vain huoltotapahtumien arvot",
|
||||
"auto_complete_on_recovery": "Suorita automaattisesti, kun anturi palautuu",
|
||||
"auto_complete_on_recovery_help": "Kirjaa suorituksen (asettaa viimeksi tehdyn), kun laukaisin poistuu itsestään — esim. suola lisätty, suodatin vaihdettu.",
|
||||
"doc_search": "Hae asiakirjoja…",
|
||||
"doc_search_none": "Ei vastaavia asiakirjoja",
|
||||
"link_device_optional": "Liitä olemassa olevaan laitteeseen (valinnainen)",
|
||||
"parent_object_optional": "Yläobjekti (valinnainen)",
|
||||
"parent_none": "(Ei yläobjektia)",
|
||||
"paused": "Keskeytetty",
|
||||
"pause_object": "Keskeytä",
|
||||
"resume_object": "Jatka",
|
||||
"pause_until_prompt": "Jäädytä tämän kohteen aikataulut — mikään ei eräänny eikä ilmoita ennen jatkamista. Aseta halutessasi automaattisen jatkamisen päivämäärä.",
|
||||
"pause_until_label": "Jatka (valinnainen)",
|
||||
"object_paused": "Kohde keskeytetty",
|
||||
"object_resumed": "Kohde jatkettu — aikataulut käynnistetty uudelleen",
|
||||
"object_paused_badge": "Keskeytetty",
|
||||
"paused_until_label": "asti",
|
||||
"replace_object": "Korvaa…",
|
||||
"replace_object_prompt": "Poista tämä kohde käytöstä ja luo seuraaja. Historia ja kustannukset pysyvät arkistoituina vanhassa; tehtävät ja asiakirjat siirtyvät uuteen, laskurit alkavat alusta.",
|
||||
"replace_name_label": "Seuraajan nimi",
|
||||
"object_replaced": "Kohde korvattu — seuraaja luotu",
|
||||
"reading_unit_label": "Lukeman yksikkö (esim. kWh, m³)",
|
||||
"reading_unit_help": "Näytetään kirjatun arvon vieressä tehtävää suoritettaessa.",
|
||||
"reading_value_label": "Lukema",
|
||||
"reading_label": "Lukema",
|
||||
"settings_templates_label": "Mallipohjagalleria",
|
||||
"settings_templates_hint": "Poista valinta mallipohjista, joita et koskaan tarvitse — ne katoavat \"Mallipohjasta\"-valitsimista (paneeli ja config flow). Mikään muu ei muutu; voi ottaa käyttöön uudelleen milloin tahansa.",
|
||||
"worksheet": "Työlomake",
|
||||
"worksheet_scan_view": "Skannaa avataksesi tehtävän",
|
||||
"worksheet_scan_complete": "Skannaa suorittaaksesi",
|
||||
"worksheet_manual_excerpt": "Ote käyttöohjeesta",
|
||||
"worksheet_pages": "sivut",
|
||||
"worksheet_printed": "Tulostettu",
|
||||
"worksheet_never": "Ei koskaan"
|
||||
}
|
||||
@@ -0,0 +1,696 @@
|
||||
{
|
||||
"maintenance": "Maintenance",
|
||||
"objects": "Objets",
|
||||
"tasks": "Tâches",
|
||||
"overdue": "En retard",
|
||||
"due_soon": "Bientôt dû",
|
||||
"triggered": "Déclenché",
|
||||
"ok": "OK",
|
||||
"all": "Tous",
|
||||
"new_object": "+ Nouvel objet",
|
||||
"templates_from": "Depuis un modèle",
|
||||
"templates_title": "Partir d'un modèle",
|
||||
"templates_task_count": "{n} tâches",
|
||||
"template_created": "Créé depuis un modèle",
|
||||
"onboard_hint": "Ajoutez votre premier objet pour suivre l'entretien.",
|
||||
"edit": "Modifier",
|
||||
"duplicate": "Dupliquer",
|
||||
"task_duplicated": "Tâche dupliquée",
|
||||
"object_duplicated": "Objet dupliqué",
|
||||
"delete": "Supprimer",
|
||||
"add_task": "+ Tâche",
|
||||
"complete": "Terminé",
|
||||
"completed": "Terminé",
|
||||
"skip": "Passer",
|
||||
"skipped": "Ignoré",
|
||||
"missed": "Missed",
|
||||
"reset": "Réinitialiser",
|
||||
"snooze": "Snooze",
|
||||
"snoozed": "Snoozed",
|
||||
"cancel": "Annuler",
|
||||
"bulk_select": "Sélectionner",
|
||||
"bulk_select_all": "Tout sélectionner",
|
||||
"bulk_n_selected": "{n} sélectionné(s)",
|
||||
"bulk_completed": "{n} tâches terminées",
|
||||
"bulk_archived": "{n} tâches archivées",
|
||||
"completing": "En cours…",
|
||||
"interval": "Intervalle",
|
||||
"warning": "Avertissement",
|
||||
"last_performed": "Dernière exécution",
|
||||
"next_due": "Prochaine échéance",
|
||||
"days_until_due": "Jours restants",
|
||||
"avg_duration": "Ø Durée",
|
||||
"trigger": "Déclencheur",
|
||||
"trigger_type": "Type de déclencheur",
|
||||
"threshold_above": "Limite supérieure",
|
||||
"threshold_below": "Limite inférieure",
|
||||
"threshold": "Seuil",
|
||||
"counter": "Compteur",
|
||||
"state_change": "Changement d'état",
|
||||
"runtime": "Durée de fonctionnement",
|
||||
"runtime_hours": "Durée cible (heures)",
|
||||
"target_value": "Valeur cible",
|
||||
"baseline": "Ligne de base",
|
||||
"target_changes": "Changements cibles",
|
||||
"for_minutes": "Pendant (minutes)",
|
||||
"time_based": "Temporel",
|
||||
"sensor_based": "Capteur",
|
||||
"manual": "Manuel",
|
||||
"one_time": "Unique",
|
||||
"weekdays": "Jours de semaine",
|
||||
"nth_weekday": "N-ième jour de la semaine du mois",
|
||||
"day_of_month": "Jour du mois",
|
||||
"recurrence_on_days": "Répéter le",
|
||||
"recurrence_occurrence": "Occurrence",
|
||||
"recurrence_weekday": "Jour de semaine",
|
||||
"recurrence_day": "Jour du mois (1–31)",
|
||||
"recurrence_last_day": "Last day of the month",
|
||||
"recurrence_business_day": "Business days only (roll back from weekend)",
|
||||
"recurrence_offset": "Offset (days, ±)",
|
||||
"recurrence_offset_help": "Shift the date by ±N days, e.g. -2 = two days before.",
|
||||
"last_day_month": "Last day of month",
|
||||
"last_business_day_month": "Last business day",
|
||||
"ord_1": "1er",
|
||||
"ord_2": "2e",
|
||||
"ord_3": "3e",
|
||||
"ord_4": "4e",
|
||||
"ord_5": "5e",
|
||||
"ord_last": "Dernier",
|
||||
"day_word": "Jour",
|
||||
"interval_value": "Intervalle",
|
||||
"interval_unit": "Unité",
|
||||
"unit_days": "Jours",
|
||||
"unit_weeks": "Semaines",
|
||||
"unit_months": "Mois",
|
||||
"unit_years": "Années",
|
||||
"due_date": "Date d'échéance",
|
||||
"cleaning": "Nettoyage",
|
||||
"inspection": "Inspection",
|
||||
"replacement": "Remplacement",
|
||||
"calibration": "Étalonnage",
|
||||
"service": "Service",
|
||||
"reading": "Relevé",
|
||||
"custom": "Personnalisé",
|
||||
"history": "Historique",
|
||||
"cost": "Coût",
|
||||
"report_button": "Rapport",
|
||||
"report_title": "Rapport d'entretien",
|
||||
"report_generated": "Généré",
|
||||
"report_times_done": "Effectué",
|
||||
"report_total_cost": "Coût total",
|
||||
"report_every": "tous les {n} {unit}",
|
||||
"report_notes": "Notes",
|
||||
"report_col_type": "Type",
|
||||
"report_col_status": "Statut",
|
||||
"report_col_schedule": "Planning",
|
||||
"duration": "Durée",
|
||||
"both": "Les deux",
|
||||
"trigger_val": "Valeur du déclencheur",
|
||||
"complete_title": "Terminé : ",
|
||||
"checklist": "Checklist",
|
||||
"checklist_steps_optional": "Étapes de la checklist (optionnel)",
|
||||
"checklist_placeholder": "Nettoyer le filtre\nRemplacer le joint\nTester la pression",
|
||||
"checklist_help": "Une étape par ligne. Max 100 éléments.",
|
||||
"err_too_long": "{field} : trop long (max {n} caractères)",
|
||||
"err_too_short": "{field} : trop court (min {n} caractères)",
|
||||
"err_value_too_high": "{field} : trop grand (max {n})",
|
||||
"err_value_too_low": "{field} : trop petit (min {n})",
|
||||
"err_required": "{field} : champ obligatoire",
|
||||
"err_wrong_type": "{field} : mauvais type (attendu : {type})",
|
||||
"err_invalid_choice": "{field} : valeur non autorisée",
|
||||
"err_invalid_value": "{field} : valeur invalide",
|
||||
"feat_schedule_time": "Planification à l'heure",
|
||||
"feat_schedule_time_desc": "Les tâches arrivent à échéance à une heure précise plutôt qu'à minuit.",
|
||||
"schedule_time_optional": "Échéance à l'heure (optionnel, HH:MM)",
|
||||
"schedule_time_help": "Vide = minuit (défaut). Fuseau horaire HA.",
|
||||
"at_time": "à",
|
||||
"notes_optional": "Notes (optionnel)",
|
||||
"cost_optional": "Coût (optionnel)",
|
||||
"duration_minutes": "Durée en minutes (optionnel)",
|
||||
"days": "jours",
|
||||
"day": "jour",
|
||||
"today": "Aujourd'hui",
|
||||
"d_overdue": "j en retard",
|
||||
"no_tasks": "Aucune tâche de maintenance. Créez un objet pour commencer.",
|
||||
"no_tasks_short": "Aucune tâche",
|
||||
"no_history": "Aucun historique.",
|
||||
"show_all": "Tout afficher",
|
||||
"cost_duration_chart": "Coûts & Durée",
|
||||
"installed": "Installé",
|
||||
"confirm_delete_object": "Supprimer cet objet et toutes ses tâches ?",
|
||||
"confirm_delete_task": "Supprimer cette tâche ?",
|
||||
"min": "Min",
|
||||
"max": "Max",
|
||||
"save": "Enregistrer",
|
||||
"saving": "Enregistrement…",
|
||||
"edit_task": "Modifier la tâche",
|
||||
"new_task": "Nouvelle tâche de maintenance",
|
||||
"task_name": "Nom de la tâche",
|
||||
"maintenance_type": "Type de maintenance",
|
||||
"priority": "Priorité",
|
||||
"labels": "Labels",
|
||||
"labels_placeholder": "e.g. safety, seasonal, tenant-visible",
|
||||
"labels_help": "Comma-separated tags for filtering and reporting.",
|
||||
"priority_low": "Basse",
|
||||
"priority_normal": "Normale",
|
||||
"priority_high": "Haute",
|
||||
"schedule_type": "Type de planification",
|
||||
"interval_days": "Intervalle (jours)",
|
||||
"warning_days": "Jours d'avertissement",
|
||||
"earliest_completion_days": "Earliest completion (days before due)",
|
||||
"earliest_completion_days_help": "Leave empty to allow completing any time. 0 = only on/after the due date.",
|
||||
"last_performed_optional": "Dernière exécution (optionnel)",
|
||||
"interval_anchor": "Ancrage de l'intervalle",
|
||||
"anchor_completion": "Depuis la date de réalisation",
|
||||
"anchor_planned": "Depuis la date prévue (sans dérive)",
|
||||
"edit_object": "Modifier l'objet",
|
||||
"name": "Nom",
|
||||
"manufacturer_optional": "Fabricant (optionnel)",
|
||||
"model_optional": "Modèle (optionnel)",
|
||||
"serial_number_optional": "Numéro de série (optionnel)",
|
||||
"serial_number_label": "N/S",
|
||||
"documentation_url_label": "Manuel",
|
||||
"object_notes_label": "Notes",
|
||||
"sort_due_date": "Échéance",
|
||||
"sort_object": "Nom de l'objet",
|
||||
"sort_type": "Type",
|
||||
"sort_task_name": "Nom de la tâche",
|
||||
"all_objects": "Tous les objets",
|
||||
"tasks_lower": "tâches",
|
||||
"no_tasks_yet": "Pas encore de tâches",
|
||||
"add_first_task": "Ajouter la première tâche",
|
||||
"trigger_configuration": "Configuration du déclencheur",
|
||||
"entity_id": "ID d'entité",
|
||||
"comma_separated": "séparé par des virgules",
|
||||
"entity_logic": "Logique d'entité",
|
||||
"entity_logic_any": "N'importe quelle entité déclenche",
|
||||
"entity_logic_all": "Toutes les entités doivent déclencher",
|
||||
"entities": "entités",
|
||||
"attribute_optional": "Attribut (optionnel, vide = état)",
|
||||
"use_entity_state": "Utiliser l'état de l'entité (pas d'attribut)",
|
||||
"trigger_above": "Déclencher au-dessus de",
|
||||
"trigger_below": "Déclencher en dessous de",
|
||||
"for_at_least_minutes": "Pendant au moins (minutes)",
|
||||
"safety_interval_days": "Intervalle de sécurité (jours, optionnel)",
|
||||
"safety_interval": "Intervalle de sécurité (optionnel)",
|
||||
"delta_mode": "Mode delta",
|
||||
"from_state_optional": "État source (optionnel)",
|
||||
"to_state_optional": "État cible (optionnel)",
|
||||
"documentation_url_optional": "URL de documentation (optionnel)",
|
||||
"object_notes_optional": "Notes (facultatif)",
|
||||
"nfc_tag_id_optional": "ID tag NFC (optionnel)",
|
||||
"nfc_tags_empty_help": "Aucun tag NFC enregistré dans Home Assistant pour le moment.",
|
||||
"nfc_tags_open_settings": "Ouvrir les réglages des tags",
|
||||
"nfc_tags_refresh": "Actualiser",
|
||||
"environmental_entity_optional": "Capteur d'environnement (optionnel)",
|
||||
"environmental_entity_helper": "ex. sensor.temperature_exterieure — ajuste l'intervalle selon les conditions environnementales",
|
||||
"environmental_attribute_optional": "Attribut d'environnement (optionnel)",
|
||||
"nfc_tag_id": "ID tag NFC",
|
||||
"nfc_linked": "Tag NFC lié",
|
||||
"nfc_link_hint": "Cliquer pour associer un tag NFC",
|
||||
"responsible_user": "Utilisateur responsable",
|
||||
"shared_with": "Shared with (rotation)",
|
||||
"shared_with_help": "Pick multiple people to share this task; the responsible person rotates on each completion.",
|
||||
"rotation_strategy": "Rotation",
|
||||
"rotation_none": "No rotation",
|
||||
"rotation_round_robin": "Round-robin",
|
||||
"rotation_least_completed": "Least completed",
|
||||
"rotation_random": "Random",
|
||||
"no_user_assigned": "(Aucun utilisateur assigné)",
|
||||
"all_users": "Tous les utilisateurs",
|
||||
"my_tasks": "Mes tâches",
|
||||
"tab_calendar": "Calendrier",
|
||||
"cal_no_events": "Aucun entretien",
|
||||
"cal_window_7": "7 jours",
|
||||
"cal_window_14": "14 jours",
|
||||
"cal_window_30": "30 jours",
|
||||
"cal_window_365": "1 an",
|
||||
"cal_every_n_days": "tous les {n} jours",
|
||||
"cal_source_time": "Basé sur le temps",
|
||||
"cal_source_time_adaptive": "Basé sur le temps (adaptatif)",
|
||||
"cal_source_sensor": "Basé sur capteur",
|
||||
"cal_predicted": "prédit",
|
||||
"cal_confidence_high": "haute confiance",
|
||||
"cal_confidence_medium": "confiance moyenne",
|
||||
"cal_confidence_low": "faible confiance",
|
||||
"budget_monthly": "Budget mensuel",
|
||||
"budget_yearly": "Budget annuel",
|
||||
"groups": "Groupes",
|
||||
"new_group": "Nouveau groupe",
|
||||
"edit_group": "Modifier le groupe",
|
||||
"no_groups": "Aucun groupe pour l'instant",
|
||||
"delete_group": "Supprimer le groupe",
|
||||
"delete_group_confirm": "Supprimer le groupe '{name}' ?",
|
||||
"group_select_tasks": "Sélectionner les tâches",
|
||||
"group_name_required": "Nom requis",
|
||||
"description_optional": "Description (optionnel)",
|
||||
"selected": "Sélectionné",
|
||||
"loading_chart": "Chargement des données...",
|
||||
"hide_outliers": "Masquer les valeurs aberrantes (erreurs capteur)",
|
||||
"was_maintenance_needed": "Cette maintenance était-elle nécessaire ?",
|
||||
"feedback_needed": "Nécessaire",
|
||||
"feedback_not_needed": "Pas nécessaire",
|
||||
"feedback_not_sure": "Pas sûr",
|
||||
"suggested_interval": "Intervalle suggéré",
|
||||
"apply_suggestion": "Appliquer",
|
||||
"reanalyze": "Réanalyser",
|
||||
"reanalyze_result": "Nouvelle analyse",
|
||||
"reanalyze_insufficient_data": "Données insuffisantes pour une recommandation",
|
||||
"data_points": "points de données",
|
||||
"dismiss_suggestion": "Ignorer",
|
||||
"confidence_low": "Faible",
|
||||
"confidence_medium": "Moyen",
|
||||
"confidence_high": "Élevé",
|
||||
"recommended": "recommandé",
|
||||
"seasonal_awareness": "Conscience saisonnière",
|
||||
"edit_seasonal_overrides": "Modifier les facteurs saisonniers",
|
||||
"seasonal_overrides_title": "Facteurs saisonniers (override)",
|
||||
"seasonal_overrides_hint": "Facteur par mois (0.1–5.0). Vide = appris automatiquement.",
|
||||
"seasonal_override_invalid": "Valeur invalide",
|
||||
"seasonal_override_range": "Le facteur doit être entre 0.1 et 5.0",
|
||||
"clear_all": "Tout effacer",
|
||||
"seasonal_chart_title": "Facteurs saisonniers",
|
||||
"seasonal_learned": "Appris",
|
||||
"seasonal_manual": "Manuel",
|
||||
"month_jan": "Jan",
|
||||
"month_feb": "Fév",
|
||||
"month_mar": "Mar",
|
||||
"month_apr": "Avr",
|
||||
"month_may": "Mai",
|
||||
"month_jun": "Juin",
|
||||
"month_jul": "Juil",
|
||||
"month_aug": "Août",
|
||||
"month_sep": "Sep",
|
||||
"month_oct": "Oct",
|
||||
"month_nov": "Nov",
|
||||
"month_dec": "Déc",
|
||||
"sensor_prediction": "Prédiction capteur",
|
||||
"degradation_trend": "Tendance",
|
||||
"trend_rising": "En hausse",
|
||||
"trend_falling": "En baisse",
|
||||
"trend_stable": "Stable",
|
||||
"trend_insufficient_data": "Données insuffisantes",
|
||||
"days_until_threshold": "Jours avant le seuil",
|
||||
"threshold_exceeded": "Seuil dépassé",
|
||||
"environmental_adjustment": "Facteur environnemental",
|
||||
"sensor_prediction_urgency": "Le capteur prévoit le seuil dans ~{days} jours",
|
||||
"day_short": "jour",
|
||||
"weibull_reliability_curve": "Courbe de fiabilité",
|
||||
"weibull_failure_probability": "Probabilité de défaillance",
|
||||
"weibull_r_squared": "Ajustement R²",
|
||||
"beta_early_failures": "Défaillances précoces",
|
||||
"beta_random_failures": "Défaillances aléatoires",
|
||||
"beta_wear_out": "Usure",
|
||||
"beta_highly_predictable": "Très prévisible",
|
||||
"confidence_interval": "Intervalle de confiance",
|
||||
"confidence_conservative": "Conservateur",
|
||||
"confidence_aggressive": "Optimiste",
|
||||
"current_interval_marker": "Intervalle actuel",
|
||||
"recommended_marker": "Recommandé",
|
||||
"characteristic_life": "Durée de vie caractéristique",
|
||||
"chart_mini_sparkline": "Sparkline de tendance",
|
||||
"chart_history": "Historique coûts et durée",
|
||||
"chart_seasonal": "Facteurs saisonniers, 12 mois",
|
||||
"chart_weibull": "Courbe de fiabilité Weibull",
|
||||
"chart_sparkline": "Graphique valeur déclencheur",
|
||||
"days_progress": "Progression en jours",
|
||||
"qr_code": "QR Code",
|
||||
"qr_generating": "Génération du QR code…",
|
||||
"qr_error": "Impossible de générer le QR code.",
|
||||
"qr_error_no_url": "Aucune URL HA configurée. Veuillez définir une URL externe ou interne dans Paramètres → Système → Réseau.",
|
||||
"save_error": "Échec de l'enregistrement. Veuillez réessayer.",
|
||||
"qr_print": "Imprimer",
|
||||
"qr_download": "Télécharger SVG",
|
||||
"qr_action": "Action au scan",
|
||||
"qr_action_view": "Afficher les infos de maintenance",
|
||||
"qr_action_complete": "Marquer la maintenance comme terminée",
|
||||
"qr_url_mode": "Type de lien",
|
||||
"qr_mode_companion": "Companion App",
|
||||
"qr_mode_local": "Local (mDNS)",
|
||||
"qr_mode_server": "URL serveur",
|
||||
"overview": "Aperçu",
|
||||
"analysis": "Analyse",
|
||||
"recent_activities": "Activités récentes",
|
||||
"search_notes": "Rechercher dans les notes",
|
||||
"avg_cost": "Ø Coût",
|
||||
"no_advanced_features": "Aucune fonction avancée activée",
|
||||
"no_advanced_features_hint": "Activez « Intervalles adaptatifs » ou « Tendances saisonnières » dans les paramètres de l'intégration pour voir les données d'analyse ici.",
|
||||
"analysis_not_enough_data": "Pas encore assez de données pour l'analyse.",
|
||||
"analysis_not_enough_data_hint": "L'analyse Weibull nécessite au moins 5 maintenances terminées ; les tendances saisonnières apparaissent après 6+ points par mois.",
|
||||
"analysis_manual_task_hint": "Les tâches manuelles sans intervalle ne génèrent pas de données d'analyse.",
|
||||
"completions": "réalisations",
|
||||
"current": "Actuel",
|
||||
"shorter": "Plus court",
|
||||
"longer": "Plus long",
|
||||
"normal": "Normal",
|
||||
"disabled": "Désactivé",
|
||||
"compound_logic": "Logique composée",
|
||||
"compound": "Composé (plusieurs conditions)",
|
||||
"compound_logic_and": "ET — toutes les conditions doivent se déclencher",
|
||||
"compound_logic_or": "OU — une seule condition suffit",
|
||||
"compound_help": "Combinez plusieurs conditions de capteurs en un déclencheur.",
|
||||
"compound_no_conditions": "Aucune condition — ajoutez-en au moins une.",
|
||||
"compound_add_condition": "Ajouter une condition",
|
||||
"compound_condition": "Condition",
|
||||
"compound_remove_condition": "Supprimer la condition",
|
||||
"card_title": "Titre",
|
||||
"card_show_header": "Afficher l'en-tête avec statistiques",
|
||||
"card_show_actions": "Afficher les boutons d'action",
|
||||
"card_compact": "Mode compact",
|
||||
"card_max_items": "Nombre max (0 = tous)",
|
||||
"card_filter_status": "Filtrer par statut",
|
||||
"card_filter_status_help": "Vide = afficher tous les statuts.",
|
||||
"card_filter_objects": "Filtrer par objets",
|
||||
"card_filter_objects_help": "Vide = afficher tous les objets.",
|
||||
"card_filter_entities": "Filtrer par entités (entity_ids)",
|
||||
"card_filter_entities_help": "Choisissez des entités sensor / binary_sensor de cette intégration. Vide = toutes.",
|
||||
"card_loading_objects": "Chargement des objets…",
|
||||
"card_load_error": "Impossible de charger les objets — vérifiez la connexion WebSocket.",
|
||||
"card_no_tasks_title": "Aucune tâche de maintenance pour l'instant",
|
||||
"card_no_tasks_cta": "→ Créez-en une dans le panneau Maintenance",
|
||||
"no_objects": "Aucun objet pour l'instant.",
|
||||
"action_error": "Action échouée. Veuillez réessayer.",
|
||||
"area_id_optional": "Zone (optionnel)",
|
||||
"installation_date_optional": "Date d'installation (optionnel)",
|
||||
"warranty_expiry_optional": "Fin de garantie (optionnel)",
|
||||
"warranty": "Garantie",
|
||||
"warranty_valid_until": "valable jusqu'au {date}",
|
||||
"warranty_expires_in": "expire dans {days} jours",
|
||||
"warranty_expired": "expirée",
|
||||
"cal_past_windows": "Fenêtres passées",
|
||||
"cal_forward_windows": "Fenêtres à venir",
|
||||
"history_edit_title": "Modifier l'entrée d'historique",
|
||||
"history_edit_timestamp": "Horodatage",
|
||||
"manufacturer": "Fabricant",
|
||||
"model": "Modèle",
|
||||
"area": "Zone",
|
||||
"actions": "Actions",
|
||||
"view_mode_label": "Vue",
|
||||
"view_cards": "Vue cartes",
|
||||
"view_table": "Vue tableau",
|
||||
"objects_table_columns_label": "Colonnes du tableau d'objets",
|
||||
"objects_table_columns_hint": "Choisissez les colonnes affichées dans la vue tableau des objets.",
|
||||
"custom_icon_optional": "Icône (optionnel, ex. mdi:wrench)",
|
||||
"task_enabled": "Tâche activée",
|
||||
"skip_reason_prompt": "Ignorer cette tâche ?",
|
||||
"reason_optional": "Raison (optionnel)",
|
||||
"reset_date_prompt": "Marquer la tâche comme effectuée ?",
|
||||
"reset_date_optional": "Date de dernière exécution (optionnel, défaut : aujourd'hui)",
|
||||
"notes_label": "Notes",
|
||||
"documentation_label": "Documentation",
|
||||
"no_nfc_tag": "— Aucun tag —",
|
||||
"dashboard": "Tableau de bord",
|
||||
"tab_today": "Aujourd'hui",
|
||||
"palette_placeholder": "Rechercher objets et tâches…",
|
||||
"palette_no_results": "Aucun résultat",
|
||||
"palette_hint": "↑↓ naviguer · Entrée ouvrir · Échap fermer",
|
||||
"today_all_caught_up": "Tout est à jour ! Rien cette semaine.",
|
||||
"today_overdue": "En retard",
|
||||
"today_due_today": "À faire aujourd'hui",
|
||||
"today_this_week": "Cette semaine",
|
||||
"settings": "Paramètres",
|
||||
"settings_features": "Fonctions avancées",
|
||||
"settings_features_desc": "Activez ou désactivez les fonctions avancées. La désactivation les masque dans l'interface mais ne supprime pas les données.",
|
||||
"feat_adaptive": "Planification adaptative",
|
||||
"feat_adaptive_desc": "Apprendre les intervalles optimaux à partir de l'historique",
|
||||
"feat_predictions": "Prédictions capteurs",
|
||||
"feat_predictions_desc": "Prédire les dates de déclenchement par dégradation des capteurs",
|
||||
"feat_seasonal": "Ajustements saisonniers",
|
||||
"feat_seasonal_desc": "Ajuster les intervalles selon les tendances saisonnières",
|
||||
"feat_environmental": "Corrélation environnementale",
|
||||
"feat_environmental_desc": "Corréler les intervalles avec la température/humidité",
|
||||
"feat_budget": "Suivi budgétaire",
|
||||
"feat_budget_desc": "Suivre les dépenses de maintenance mensuelles et annuelles",
|
||||
"feat_groups": "Groupes de tâches",
|
||||
"feat_groups_desc": "Organiser les tâches en groupes logiques",
|
||||
"feat_checklists": "Checklists",
|
||||
"feat_checklists_desc": "Procédures multi-étapes pour la réalisation des tâches",
|
||||
"settings_general": "Général",
|
||||
"settings_default_warning": "Jours d'avertissement par défaut",
|
||||
"settings_panel_enabled": "Panneau latéral",
|
||||
"settings_panel_title": "Titre du panneau latéral",
|
||||
"settings_notifications": "Notifications",
|
||||
"settings_notify_service": "Service de notification",
|
||||
"test_notification": "Notification de test",
|
||||
"send_test": "Envoyer le test",
|
||||
"testing": "Envoi en cours…",
|
||||
"test_notification_success": "Notification de test envoyée",
|
||||
"test_notification_failed": "Échec de la notification de test",
|
||||
"settings_notify_due_soon": "Notifier quand bientôt dû",
|
||||
"settings_notify_overdue": "Notifier quand en retard",
|
||||
"settings_notify_triggered": "Notifier quand déclenché",
|
||||
"settings_interval_hours": "Intervalle de répétition (heures, 0 = une fois)",
|
||||
"settings_quiet_hours": "Heures de silence",
|
||||
"settings_quiet_start": "Début",
|
||||
"settings_quiet_end": "Fin",
|
||||
"settings_max_per_day": "Max notifications par jour (0 = illimité)",
|
||||
"settings_bundling": "Regrouper les notifications",
|
||||
"settings_bundle_threshold": "Seuil de regroupement",
|
||||
"settings_reminder_leads": "Extra reminders (days before due)",
|
||||
"settings_reminder_leads_hint": "Comma-separated lead times, e.g. 14, 3, 0 — one extra reminder fires on each matching day. Empty = off.",
|
||||
"settings_actions": "Boutons d'action mobiles",
|
||||
"settings_action_complete": "Afficher le bouton 'Terminé'",
|
||||
"settings_action_skip": "Afficher le bouton 'Passer'",
|
||||
"settings_action_snooze": "Afficher le bouton 'Reporter'",
|
||||
"settings_weekly_digest": "Récapitulatif hebdomadaire",
|
||||
"settings_weekly_digest_hint": "Une notification récapitulative le lundi matin quand des tâches sont dues.",
|
||||
"settings_warranty_reminder": "Warranty expiry reminder",
|
||||
"settings_warranty_reminder_days": "Days before expiry",
|
||||
"settings_warranty_reminder_hint": "Notify once when an object's warranty is this many days from expiring.",
|
||||
"settings_snooze_hours": "Durée de report (heures)",
|
||||
"settings_budget": "Budget",
|
||||
"settings_currency": "Devise",
|
||||
"settings_budget_monthly": "Budget mensuel",
|
||||
"settings_budget_yearly": "Budget annuel",
|
||||
"settings_budget_alerts": "Alertes budgétaires",
|
||||
"settings_budget_threshold": "Seuil d'alerte (%)",
|
||||
"settings_import_export": "Import / Export",
|
||||
"settings_export_json": "Exporter JSON",
|
||||
"settings_export_yaml": "Exporter YAML",
|
||||
"settings_export_csv": "Exporter CSV",
|
||||
"settings_import_csv": "Importer CSV",
|
||||
"settings_import_placeholder": "Collez le contenu JSON ou CSV ici…",
|
||||
"settings_import_btn": "Importer",
|
||||
"settings_import_success": "{count} objets importés avec succès.",
|
||||
"settings_export_success": "Export téléchargé.",
|
||||
"settings_saved": "Paramètre enregistré.",
|
||||
"settings_include_history": "Inclure l'historique",
|
||||
"sort_alphabetical": "Alphabétique",
|
||||
"sort_due_soonest": "Échéance la plus proche",
|
||||
"sort_task_count": "Nombre de tâches",
|
||||
"sort_area": "Zone",
|
||||
"sort_assigned_user": "Utilisateur affecté",
|
||||
"sort_group": "Groupe",
|
||||
"groupby_none": "Aucun groupement",
|
||||
"groupby_area": "Par zone",
|
||||
"groupby_group": "Par groupe",
|
||||
"groupby_user": "Par utilisateur",
|
||||
"filter_label": "Filtre",
|
||||
"user_label": "Utilisateur",
|
||||
"sort_label": "Tri",
|
||||
"group_by_label": "Grouper par",
|
||||
"state_value_help": "Utilisez la valeur d'état HA (généralement en minuscules, p. ex. \"on\"/\"off\"). La casse est normalisée à l'enregistrement.",
|
||||
"target_changes_help": "Nombre de transitions correspondantes avant le déclenchement (par défaut : 1).",
|
||||
"qr_print_title": "Imprimer les QR codes",
|
||||
"qr_print_desc": "Générer une page imprimable de QR codes à découper et coller sur votre équipement.",
|
||||
"qr_print_load": "Charger les objets",
|
||||
"qr_print_filter": "Filtre",
|
||||
"qr_print_objects": "Objets",
|
||||
"qr_print_actions": "Actions",
|
||||
"qr_print_url_mode": "Type de lien",
|
||||
"qr_print_estimate": "QR codes estimés",
|
||||
"qr_print_over_limit": "limite à 200, affinez le filtre",
|
||||
"qr_print_generate": "Générer les QR codes",
|
||||
"qr_print_generating": "Génération…",
|
||||
"qr_print_ready": "QR codes prêts",
|
||||
"qr_print_print_button": "Imprimer",
|
||||
"qr_print_empty": "Rien à générer",
|
||||
"qr_action_skip": "Passer",
|
||||
"vacation_title": "Mode vacances",
|
||||
"vacation_active": "actif",
|
||||
"vacation_ended": "terminé",
|
||||
"vacation_desc": "Planifiez vos vacances : les notifications sont mises en pause pendant la période plus quelques jours tampon. Vous pouvez réactiver certaines tâches.",
|
||||
"vacation_enable": "Activer le mode vacances",
|
||||
"vacation_start": "Début",
|
||||
"vacation_end": "Fin",
|
||||
"vacation_buffer": "Tampon (jours)",
|
||||
"vacation_exempt_title": "Notifier malgré les vacances",
|
||||
"vacation_exempt_desc": "Choisissez les tâches qui doivent quand même notifier pendant les vacances (p. ex. chimie de piscine critique).",
|
||||
"vacation_load_tasks": "Charger les tâches",
|
||||
"vacation_preview_btn": "Afficher l'aperçu",
|
||||
"vacation_preview_affected": "tâches concernées",
|
||||
"vacation_event_due_soon": "bientôt dû",
|
||||
"vacation_event_overdue": "deviendra en retard",
|
||||
"vacation_event_triggered_est": "déclencheur capteur possible",
|
||||
"vacation_sensor_based": "(basé sur capteur)",
|
||||
"vacation_action_notify": "Notifier quand même",
|
||||
"vacation_action_unsilence": "Mettre en silence",
|
||||
"vacation_marked_complete": "Marqué comme terminé",
|
||||
"vacation_marked_skip": "Passé",
|
||||
"vacation_end_now": "Terminer les vacances maintenant",
|
||||
"unassigned": "Non assigné",
|
||||
"no_area": "Aucune zone",
|
||||
"has_overdue": "Tâches en retard",
|
||||
"object": "Objet",
|
||||
"settings_panel_access": "Accès au panneau",
|
||||
"settings_panel_access_desc": "Les administrateurs ont toujours un accès complet. Pour déléguer la création, la modification et la suppression à des non-administrateurs spécifiques, activez ceci et sélectionnez-les ci-dessous — les autres ne voient que Terminer et Ignorer.",
|
||||
"settings_operator_write": "Autoriser les utilisateurs sélectionnés à créer, modifier et supprimer",
|
||||
"settings_operator_write_desc": "Désactivé : seuls les administrateurs peuvent modifier le contenu. Activé : les utilisateurs ci-dessous ont aussi un accès complet.",
|
||||
"no_non_admin_users": "Aucun utilisateur non administrateur trouvé. Ajoutez-en dans Paramètres → Personnes.",
|
||||
"owner_label": "Propriétaire",
|
||||
"feat_completion_actions": "Actions de finalisation",
|
||||
"feat_completion_actions_desc": "Action HA par tâche lors de la finalisation + QR de finalisation rapide avec valeurs pré-définies.",
|
||||
"on_complete_action_title": "À la finalisation : déclencher une action HA (optionnel)",
|
||||
"on_complete_action_desc": "Appelle un service HA quand la tâche est terminée — p. ex. réinitialiser un compteur sur l'appareil.",
|
||||
"on_complete_action_service": "Service",
|
||||
"on_complete_action_target": "Entité cible",
|
||||
"on_complete_action_data": "Données (JSON, optionnel)",
|
||||
"on_complete_action_test": "Tester l'action",
|
||||
"on_complete_action_test_success": "Réussi",
|
||||
"on_complete_action_test_failed": "Échoué",
|
||||
"quick_complete_defaults_title": "Valeurs par défaut pour finalisation rapide (scans QR, optionnel)",
|
||||
"quick_complete_defaults_desc": "Valeurs pré-définies pour les scans QR de finalisation rapide. Sans ces valeurs, le QR ouvre la boîte de dialogue.",
|
||||
"quick_complete_defaults_notes": "Notes",
|
||||
"quick_complete_defaults_cost": "Coût",
|
||||
"quick_complete_defaults_duration": "Durée (minutes)",
|
||||
"quick_complete_defaults_feedback_none": "Aucun feedback",
|
||||
"quick_complete_defaults_feedback_needed": "Était nécessaire",
|
||||
"quick_complete_defaults_feedback_not_needed": "Non nécessaire",
|
||||
"quick_complete_success": "Terminé rapidement",
|
||||
"trigger_replaced": "Déclencheur remplacé",
|
||||
"add": "Ajouter",
|
||||
"show_stats": "Afficher les statistiques + graphiques",
|
||||
"hide_stats": "Masquer les statistiques",
|
||||
"adaptive_no_data": "Pas encore assez d'historique de réalisations pour l'analyse adaptative. Réalisez cette tâche encore quelques fois pour débloquer les recommandations d'intervalle et les courbes de fiabilité.",
|
||||
"suggestion_applied": "Intervalle suggéré appliqué",
|
||||
"vacation_mode": "Mode vacances",
|
||||
"vacation_status_active": "Actif maintenant",
|
||||
"vacation_status_scheduled": "Planifié",
|
||||
"vacation_status_inactive": "Inactif",
|
||||
"vacation_end_now_confirm": "Terminer les vacances immédiatement ?",
|
||||
"vacation_exempt_count": "exemptées",
|
||||
"vacation_advanced": "Avancé…",
|
||||
"vacation_open_panel": "Ouvrir dans le panneau",
|
||||
"enable": "Activer",
|
||||
"saved": "Enregistré",
|
||||
"budget_monthly_set": "Définir mensuel",
|
||||
"budget_yearly_set": "Définir annuel",
|
||||
"budget_advanced": "Devise, alertes…",
|
||||
"budget_open_panel": "Ouvrir dans le panneau",
|
||||
"groups_empty": "Aucun groupe pour le moment.",
|
||||
"group_new_placeholder": "Ajouter un groupe…",
|
||||
"group_delete_confirm": "Supprimer le groupe « {name} » ?",
|
||||
"groups_manage_tasks": "Gérer les affectations de tâches…",
|
||||
"groups_open_panel": "Ouvrir dans le panneau",
|
||||
"on_complete_action_target_hint": "Note : le domaine de l'entité doit correspondre au service — p. ex. 'button.press' ne fonctionne que sur button.*, 'counter.increment' seulement sur counter.*, 'input_button.press' seulement sur input_button.* etc. En cas de non-correspondance, l'action échoue silencieusement (HA journalise 'Referenced entities ... missing or not currently available').",
|
||||
"show_all_objects": "Afficher tous les objets",
|
||||
"show_all_tasks": "Effacer le filtre — afficher toutes les tâches",
|
||||
"filter_to_overdue": "Filtrer la liste sur les tâches en retard uniquement",
|
||||
"filter_to_due_soon": "Filtrer la liste sur les tâches bientôt dues uniquement",
|
||||
"filter_to_triggered": "Filtrer la liste sur les tâches déclenchées uniquement",
|
||||
"open_task": "Ouvrir la tâche",
|
||||
"show_details": "Afficher l'historique + statistiques",
|
||||
"hide_details": "Masquer les détails",
|
||||
"history_empty": "Aucun historique pour le moment.",
|
||||
"history_edit_button": "Modifier l'entrée",
|
||||
"total_cost": "Coût total",
|
||||
"times_performed": "Réalisée",
|
||||
"older_entries": "plus anciennes",
|
||||
"open_in_panel": "Ouvrir dans le panneau Maintenance",
|
||||
"skip_reason": "Raison du saut (facultatif)",
|
||||
"reset_to_date": "Réinitialiser la dernière réalisation au",
|
||||
"delete_task_confirm": "Supprimer cette tâche et son historique ?",
|
||||
"delete_object_confirm": "Supprimer cet objet et toutes ses tâches ?",
|
||||
"loading": "Chargement…",
|
||||
"archive": "Archiver",
|
||||
"undo": "Annuler",
|
||||
"task_archived": "Tâche archivée",
|
||||
"object_archived": "Objet archivé",
|
||||
"unarchive": "Désarchiver",
|
||||
"archived": "Archivé",
|
||||
"show_archived": "Afficher les archivés",
|
||||
"hide_archived": "Masquer les archivés",
|
||||
"confirm_archive_object": "Archiver cet objet et ses tâches ? L'historique est conservé et peut être restauré plus tard.",
|
||||
"settings_archive": "Archivage et conservation",
|
||||
"settings_archive_desc": "Mettez de côté les tâches ponctuelles terminées sans les supprimer. Les éléments archivés sont masqués et inactifs, mais conservent leur historique et leurs coûts.",
|
||||
"settings_archive_oneoff_days": "Archiver automatiquement les tâches ponctuelles terminées après (jours, 0 = désactivé)",
|
||||
"settings_delete_archived_oneoff_days": "Supprimer automatiquement les tâches ponctuelles archivées après (jours, 0 = jamais)",
|
||||
"archive_object": "Archiver l'objet",
|
||||
"unarchive_object": "Désarchiver l'objet",
|
||||
"documents": "Documents",
|
||||
"documents_empty": "Aucun document pour l'instant.",
|
||||
"doc_upload": "Téléverser un fichier",
|
||||
"doc_uploading": "Téléversement…",
|
||||
"doc_add_link": "Ajouter un lien",
|
||||
"doc_link_url": "URL (https://…)",
|
||||
"doc_link_title": "Titre (facultatif)",
|
||||
"doc_open": "Ouvrir",
|
||||
"doc_delete_confirm": "Supprimer « {name} » ?",
|
||||
"doc_too_large": "Fichier trop volumineux (max. 25 Mo).",
|
||||
"doc_upload_failed": "Échec du téléversement.",
|
||||
"completion_photo_optional": "Completion photo (optional)",
|
||||
"add_photo": "Add photo",
|
||||
"uploading": "Uploading…",
|
||||
"remove": "Remove",
|
||||
"doc_deduped": "Déjà stocké ailleurs — partagé, aucun espace supplémentaire.",
|
||||
"doc_dup_in_object": "Ce fichier est déjà attaché à cet objet.",
|
||||
"doc_link_invalid": "Seuls les liens http/https sont autorisés.",
|
||||
"doc_cat_manual": "Manuel",
|
||||
"doc_cat_warranty": "Garantie",
|
||||
"doc_cat_invoice": "Facture",
|
||||
"doc_cat_spare_parts": "Pièces détachées",
|
||||
"doc_cat_photo": "Photo",
|
||||
"doc_cat_other": "Autre",
|
||||
"doc_link_badge": "Lien",
|
||||
"doc_storage_title": "Stockage des documents",
|
||||
"doc_storage_saved": "Économisé par déduplication",
|
||||
"doc_storage_refresh": "Actualiser",
|
||||
"doc_download": "Télécharger",
|
||||
"doc_close": "Fermer",
|
||||
"doc_camera": "Prendre une photo",
|
||||
"doc_drop_hint": "Déposez les fichiers ici",
|
||||
"doc_task_none": "Aucun document lié à cette tâche.",
|
||||
"doc_link_existing": "Lier un document…",
|
||||
"doc_attach": "Lier",
|
||||
"doc_unlink": "Dissocier",
|
||||
"doc_page": "Page",
|
||||
"chart_range_7d": "7j",
|
||||
"chart_range_30d": "30j",
|
||||
"chart_range_90d": "90j",
|
||||
"chart_range_1y": "1a",
|
||||
"chart_since_service": "depuis le dernier entretien",
|
||||
"chart_no_stats": "Pas de statistiques à long terme pour cette entité — seules les valeurs des entretiens sont affichées",
|
||||
"auto_complete_on_recovery": "Terminer automatiquement quand le capteur se rétablit",
|
||||
"auto_complete_on_recovery_help": "Enregistre une exécution (met à jour la dernière réalisation) lorsque le déclencheur se résout de lui-même — p. ex. sel rechargé, filtre remplacé.",
|
||||
"doc_search": "Rechercher des documents…",
|
||||
"doc_search_none": "Aucun document correspondant",
|
||||
"link_device_optional": "Lier à un appareil existant (optionnel)",
|
||||
"parent_object_optional": "Objet parent (optionnel)",
|
||||
"parent_none": "(Aucun parent)",
|
||||
"paused": "En pause",
|
||||
"pause_object": "Mettre en pause",
|
||||
"resume_object": "Reprendre",
|
||||
"pause_until_prompt": "Gèle les calendriers de cet objet — rien n'arrive à échéance et rien ne notifie jusqu'à la reprise. Optionnellement, fixez une date de reprise automatique.",
|
||||
"pause_until_label": "Reprendre le (optionnel)",
|
||||
"object_paused": "Objet mis en pause",
|
||||
"object_resumed": "Objet repris — calendriers relancés",
|
||||
"object_paused_badge": "En pause",
|
||||
"paused_until_label": "jusqu'au",
|
||||
"replace_object": "Remplacer…",
|
||||
"replace_object_prompt": "Retire cet objet et crée un successeur. L'historique et les coûts restent archivés sur l'ancien ; les tâches et documents passent au nouveau, les compteurs repartent de zéro.",
|
||||
"replace_name_label": "Nom du successeur",
|
||||
"object_replaced": "Objet remplacé — successeur créé",
|
||||
"reading_unit_label": "Unité de relevé (p. ex. kWh, m³)",
|
||||
"reading_unit_help": "Affichée à côté de la valeur enregistrée lors de l'exécution de cette tâche.",
|
||||
"reading_value_label": "Valeur relevée",
|
||||
"reading_label": "Relevé",
|
||||
"settings_templates_label": "Galerie de modèles",
|
||||
"settings_templates_hint": "Décochez les modèles dont vous n'aurez jamais besoin — ils disparaissent des sélecteurs « Depuis un modèle » (panneau et config flow). Rien d'autre ne change ; réactivables à tout moment.",
|
||||
"worksheet": "Fiche de travail",
|
||||
"worksheet_scan_view": "Scanner pour ouvrir la tâche",
|
||||
"worksheet_scan_complete": "Scanner pour terminer",
|
||||
"worksheet_manual_excerpt": "Extrait du manuel",
|
||||
"worksheet_pages": "pages",
|
||||
"worksheet_printed": "Imprimé",
|
||||
"worksheet_never": "Jamais"
|
||||
}
|
||||
@@ -0,0 +1,696 @@
|
||||
{
|
||||
"maintenance": "रखरखाव",
|
||||
"objects": "वस्तुएँ",
|
||||
"tasks": "कार्य",
|
||||
"overdue": "अतिदेय",
|
||||
"due_soon": "जल्द देय",
|
||||
"triggered": "ट्रिगर हुआ",
|
||||
"trigger_replaced": "ट्रिगर बदला गया",
|
||||
"ok": "ठीक",
|
||||
"all": "सभी",
|
||||
"new_object": "+ नई वस्तु",
|
||||
"templates_from": "टेम्पलेट से",
|
||||
"templates_title": "टेम्पलेट से शुरू करें",
|
||||
"templates_task_count": "{n} कार्य",
|
||||
"template_created": "टेम्पलेट से बनाया गया",
|
||||
"onboard_hint": "रखरखाव ट्रैक करने के लिए अपना पहला ऑब्जेक्ट जोड़ें।",
|
||||
"edit": "संपादित करें",
|
||||
"duplicate": "डुप्लिकेट करें",
|
||||
"task_duplicated": "कार्य डुप्लिकेट किया गया",
|
||||
"object_duplicated": "ऑब्जेक्ट डुप्लिकेट किया गया",
|
||||
"delete": "हटाएँ",
|
||||
"add_task": "+ कार्य जोड़ें",
|
||||
"complete": "पूर्ण करें",
|
||||
"completed": "पूर्ण हुआ",
|
||||
"skip": "छोड़ें",
|
||||
"skipped": "छोड़ा गया",
|
||||
"missed": "Missed",
|
||||
"reset": "रीसेट करें",
|
||||
"snooze": "Snooze",
|
||||
"snoozed": "Snoozed",
|
||||
"cancel": "रद्द करें",
|
||||
"bulk_select": "चुनें",
|
||||
"bulk_select_all": "सभी चुनें",
|
||||
"bulk_n_selected": "{n} चयनित",
|
||||
"bulk_completed": "{n} कार्य पूर्ण",
|
||||
"bulk_archived": "{n} कार्य संग्रहित",
|
||||
"completing": "पूर्ण किया जा रहा है…",
|
||||
"interval": "अंतराल",
|
||||
"warning": "चेतावनी",
|
||||
"last_performed": "अंतिम बार किया गया",
|
||||
"next_due": "अगली देय",
|
||||
"days_until_due": "देय तक दिन",
|
||||
"avg_duration": "औसत अवधि",
|
||||
"trigger": "ट्रिगर",
|
||||
"trigger_type": "ट्रिगर प्रकार",
|
||||
"threshold_above": "ऊपरी सीमा",
|
||||
"threshold_below": "निचली सीमा",
|
||||
"threshold": "सीमा",
|
||||
"counter": "काउंटर",
|
||||
"state_change": "स्थिति परिवर्तन",
|
||||
"runtime": "रनटाइम",
|
||||
"runtime_hours": "लक्ष्य रनटाइम (घंटे)",
|
||||
"target_value": "लक्ष्य मान",
|
||||
"baseline": "आधार रेखा",
|
||||
"target_changes": "लक्ष्य परिवर्तन",
|
||||
"for_minutes": "के लिए (मिनट)",
|
||||
"time_based": "समय-आधारित",
|
||||
"sensor_based": "सेंसर-आधारित",
|
||||
"manual": "मैनुअल",
|
||||
"one_time": "एक बार",
|
||||
"weekdays": "सप्ताह के दिन",
|
||||
"nth_weekday": "महीने का N-वाँ सप्ताह-दिन",
|
||||
"day_of_month": "महीने का दिन",
|
||||
"recurrence_on_days": "दोहराएँ",
|
||||
"recurrence_occurrence": "घटना",
|
||||
"recurrence_weekday": "सप्ताह-दिन",
|
||||
"recurrence_day": "महीने का दिन (1–31)",
|
||||
"recurrence_last_day": "Last day of the month",
|
||||
"recurrence_business_day": "Business days only (roll back from weekend)",
|
||||
"recurrence_offset": "Offset (days, ±)",
|
||||
"recurrence_offset_help": "Shift the date by ±N days, e.g. -2 = two days before.",
|
||||
"last_day_month": "Last day of month",
|
||||
"last_business_day_month": "Last business day",
|
||||
"ord_1": "पहला",
|
||||
"ord_2": "दूसरा",
|
||||
"ord_3": "तीसरा",
|
||||
"ord_4": "चौथा",
|
||||
"ord_5": "पाँचवाँ",
|
||||
"ord_last": "अंतिम",
|
||||
"day_word": "दिन",
|
||||
"interval_value": "अंतराल",
|
||||
"interval_unit": "इकाई",
|
||||
"unit_days": "दिन",
|
||||
"unit_weeks": "सप्ताह",
|
||||
"unit_months": "महीने",
|
||||
"unit_years": "वर्ष",
|
||||
"due_date": "देय तिथि",
|
||||
"cleaning": "सफ़ाई",
|
||||
"inspection": "निरीक्षण",
|
||||
"replacement": "प्रतिस्थापन",
|
||||
"calibration": "अंशांकन",
|
||||
"service": "सर्विस",
|
||||
"reading": "रीडिंग",
|
||||
"custom": "कस्टम",
|
||||
"history": "इतिहास",
|
||||
"cost": "लागत",
|
||||
"report_button": "रिपोर्ट",
|
||||
"report_title": "रखरखाव रिपोर्ट",
|
||||
"report_generated": "जनरेट किया गया",
|
||||
"report_times_done": "पूर्ण",
|
||||
"report_total_cost": "कुल लागत",
|
||||
"report_every": "हर {n} {unit}",
|
||||
"report_notes": "नोट्स",
|
||||
"report_col_type": "प्रकार",
|
||||
"report_col_status": "स्थिति",
|
||||
"report_col_schedule": "शेड्यूल",
|
||||
"duration": "अवधि",
|
||||
"both": "दोनों",
|
||||
"trigger_val": "ट्रिगर मान",
|
||||
"complete_title": "पूर्ण करें: ",
|
||||
"checklist": "चेकलिस्ट",
|
||||
"checklist_steps_optional": "चेकलिस्ट चरण (वैकल्पिक)",
|
||||
"checklist_placeholder": "फ़िल्टर साफ़ करें\nसील बदलें\nदबाव जाँचें",
|
||||
"checklist_help": "प्रति पंक्ति एक चरण। अधिकतम 100 आइटम।",
|
||||
"err_too_long": "{field}: बहुत लंबा (अधिकतम {n} अक्षर)",
|
||||
"err_too_short": "{field}: बहुत छोटा (न्यूनतम {n} अक्षर)",
|
||||
"err_value_too_high": "{field}: बहुत बड़ा (अधिकतम {n})",
|
||||
"err_value_too_low": "{field}: बहुत छोटा (न्यूनतम {n})",
|
||||
"err_required": "{field}: आवश्यक",
|
||||
"err_wrong_type": "{field}: गलत प्रकार (अपेक्षित: {type})",
|
||||
"err_invalid_choice": "{field}: अनुमत मान नहीं है",
|
||||
"err_invalid_value": "{field}: अमान्य मान",
|
||||
"feat_schedule_time": "दिन का समय निर्धारण",
|
||||
"feat_schedule_time_desc": "कार्य मध्यरात्रि के बजाय दिन के एक विशिष्ट समय पर अतिदेय हो जाते हैं।",
|
||||
"schedule_time_optional": "इस समय देय (वैकल्पिक, HH:MM)",
|
||||
"schedule_time_help": "खाली = मध्यरात्रि (डिफ़ॉल्ट)। HA समय क्षेत्र।",
|
||||
"at_time": "बजे",
|
||||
"notes_optional": "टिप्पणियाँ (वैकल्पिक)",
|
||||
"cost_optional": "लागत (वैकल्पिक)",
|
||||
"duration_minutes": "मिनटों में अवधि (वैकल्पिक)",
|
||||
"days": "दिन",
|
||||
"day": "दिन",
|
||||
"today": "आज",
|
||||
"d_overdue": "दिन अतिदेय",
|
||||
"no_tasks": "अभी कोई रखरखाव कार्य नहीं। शुरू करने के लिए एक वस्तु बनाएँ।",
|
||||
"no_tasks_short": "कोई कार्य नहीं",
|
||||
"no_history": "अभी कोई इतिहास प्रविष्टि नहीं।",
|
||||
"show_all": "सभी दिखाएँ",
|
||||
"cost_duration_chart": "लागत और अवधि",
|
||||
"installed": "स्थापित",
|
||||
"confirm_delete_object": "इस वस्तु और इसके सभी कार्यों को हटाएँ?",
|
||||
"confirm_delete_task": "इस कार्य को हटाएँ?",
|
||||
"min": "न्यूनतम",
|
||||
"max": "अधिकतम",
|
||||
"save": "सहेजें",
|
||||
"saving": "सहेजा जा रहा है…",
|
||||
"edit_task": "कार्य संपादित करें",
|
||||
"new_task": "नया रखरखाव कार्य",
|
||||
"task_name": "कार्य का नाम",
|
||||
"maintenance_type": "रखरखाव प्रकार",
|
||||
"priority": "प्राथमिकता",
|
||||
"labels": "Labels",
|
||||
"labels_placeholder": "e.g. safety, seasonal, tenant-visible",
|
||||
"labels_help": "Comma-separated tags for filtering and reporting.",
|
||||
"priority_low": "कम",
|
||||
"priority_normal": "सामान्य",
|
||||
"priority_high": "उच्च",
|
||||
"schedule_type": "अनुसूची प्रकार",
|
||||
"interval_days": "अंतराल (दिन)",
|
||||
"warning_days": "चेतावनी दिन",
|
||||
"earliest_completion_days": "Earliest completion (days before due)",
|
||||
"earliest_completion_days_help": "Leave empty to allow completing any time. 0 = only on/after the due date.",
|
||||
"last_performed_optional": "अंतिम बार किया गया (वैकल्पिक)",
|
||||
"interval_anchor": "अंतराल आधार",
|
||||
"anchor_completion": "पूर्ण तिथि से",
|
||||
"anchor_planned": "नियोजित तिथि से (कोई विचलन नहीं)",
|
||||
"edit_object": "वस्तु संपादित करें",
|
||||
"name": "नाम",
|
||||
"manufacturer_optional": "निर्माता (वैकल्पिक)",
|
||||
"model_optional": "मॉडल (वैकल्पिक)",
|
||||
"serial_number_optional": "सीरियल नंबर (वैकल्पिक)",
|
||||
"serial_number_label": "S/N",
|
||||
"documentation_url_label": "मैनुअल",
|
||||
"object_notes_label": "टिप्पणियाँ",
|
||||
"sort_due_date": "देय तिथि",
|
||||
"sort_object": "वस्तु का नाम",
|
||||
"sort_type": "प्रकार",
|
||||
"sort_task_name": "कार्य का नाम",
|
||||
"all_objects": "सभी वस्तुएँ",
|
||||
"tasks_lower": "कार्य",
|
||||
"no_tasks_yet": "अभी कोई कार्य नहीं",
|
||||
"add_first_task": "पहला कार्य जोड़ें",
|
||||
"trigger_configuration": "ट्रिगर कॉन्फ़िगरेशन",
|
||||
"entity_id": "एंटिटी ID",
|
||||
"comma_separated": "अल्पविराम से अलग",
|
||||
"entity_logic": "एंटिटी लॉजिक",
|
||||
"entity_logic_any": "कोई भी एंटिटी ट्रिगर करती है",
|
||||
"entity_logic_all": "सभी एंटिटी ट्रिगर होनी चाहिए",
|
||||
"entities": "एंटिटी",
|
||||
"attribute_optional": "विशेषता (वैकल्पिक, खाली = स्थिति)",
|
||||
"use_entity_state": "एंटिटी स्थिति का उपयोग करें (कोई विशेषता नहीं)",
|
||||
"trigger_above": "इससे ऊपर ट्रिगर करें",
|
||||
"trigger_below": "इससे नीचे ट्रिगर करें",
|
||||
"for_at_least_minutes": "कम से कम (मिनट)",
|
||||
"safety_interval_days": "सुरक्षा अंतराल (दिन, वैकल्पिक)",
|
||||
"safety_interval": "सुरक्षा अंतराल (वैकल्पिक)",
|
||||
"delta_mode": "डेल्टा मोड",
|
||||
"from_state_optional": "किस स्थिति से (वैकल्पिक)",
|
||||
"to_state_optional": "किस स्थिति तक (वैकल्पिक)",
|
||||
"documentation_url_optional": "दस्तावेज़ URL (वैकल्पिक)",
|
||||
"object_notes_optional": "टिप्पणियाँ (वैकल्पिक)",
|
||||
"nfc_tag_id_optional": "NFC टैग ID (वैकल्पिक)",
|
||||
"nfc_tags_empty_help": "Home Assistant में अभी कोई NFC टैग पंजीकृत नहीं है।",
|
||||
"nfc_tags_open_settings": "टैग सेटिंग्स खोलें",
|
||||
"nfc_tags_refresh": "रिफ़्रेश करें",
|
||||
"environmental_entity_optional": "पर्यावरण सेंसर (वैकल्पिक)",
|
||||
"environmental_entity_helper": "उदा. sensor.outdoor_temperature — पर्यावरणीय स्थितियों के आधार पर अंतराल समायोजित करता है",
|
||||
"environmental_attribute_optional": "पर्यावरण विशेषता (वैकल्पिक)",
|
||||
"nfc_tag_id": "NFC टैग ID",
|
||||
"nfc_linked": "NFC टैग लिंक किया गया",
|
||||
"nfc_link_hint": "NFC टैग लिंक करने के लिए क्लिक करें",
|
||||
"responsible_user": "जिम्मेदार उपयोगकर्ता",
|
||||
"shared_with": "Shared with (rotation)",
|
||||
"shared_with_help": "Pick multiple people to share this task; the responsible person rotates on each completion.",
|
||||
"rotation_strategy": "Rotation",
|
||||
"rotation_none": "No rotation",
|
||||
"rotation_round_robin": "Round-robin",
|
||||
"rotation_least_completed": "Least completed",
|
||||
"rotation_random": "Random",
|
||||
"no_user_assigned": "(कोई उपयोगकर्ता असाइन नहीं)",
|
||||
"all_users": "सभी उपयोगकर्ता",
|
||||
"my_tasks": "मेरे कार्य",
|
||||
"tab_calendar": "कैलेंडर",
|
||||
"cal_no_events": "कोई रखरखाव नहीं",
|
||||
"cal_window_7": "7 दिन",
|
||||
"cal_window_14": "14 दिन",
|
||||
"cal_window_30": "30 दिन",
|
||||
"cal_window_365": "1 वर्ष",
|
||||
"cal_every_n_days": "हर {n} दिन",
|
||||
"cal_source_time": "समय-आधारित",
|
||||
"cal_source_time_adaptive": "समय-आधारित (अनुकूली)",
|
||||
"cal_source_sensor": "सेंसर-आधारित",
|
||||
"cal_predicted": "अनुमानित",
|
||||
"cal_confidence_high": "उच्च विश्वास",
|
||||
"cal_confidence_medium": "मध्यम विश्वास",
|
||||
"cal_confidence_low": "निम्न विश्वास",
|
||||
"budget_monthly": "मासिक बजट",
|
||||
"budget_yearly": "वार्षिक बजट",
|
||||
"groups": "समूह",
|
||||
"new_group": "नया समूह",
|
||||
"edit_group": "समूह संपादित करें",
|
||||
"no_groups": "अभी कोई समूह नहीं",
|
||||
"delete_group": "समूह हटाएँ",
|
||||
"delete_group_confirm": "समूह '{name}' हटाएँ?",
|
||||
"group_select_tasks": "कार्य चुनें",
|
||||
"group_name_required": "नाम आवश्यक है",
|
||||
"description_optional": "विवरण (वैकल्पिक)",
|
||||
"selected": "चयनित",
|
||||
"loading_chart": "चार्ट डेटा लोड हो रहा है...",
|
||||
"hide_outliers": "आउटलायर छिपाएँ (सेंसर गड़बड़)",
|
||||
"was_maintenance_needed": "क्या यह रखरखाव आवश्यक था?",
|
||||
"feedback_needed": "आवश्यक था",
|
||||
"feedback_not_needed": "आवश्यक नहीं",
|
||||
"feedback_not_sure": "निश्चित नहीं",
|
||||
"suggested_interval": "सुझाया गया अंतराल",
|
||||
"apply_suggestion": "लागू करें",
|
||||
"reanalyze": "पुनः विश्लेषण करें",
|
||||
"reanalyze_result": "नया विश्लेषण",
|
||||
"reanalyze_insufficient_data": "अनुशंसा देने के लिए पर्याप्त डेटा नहीं",
|
||||
"data_points": "डेटा बिंदु",
|
||||
"dismiss_suggestion": "खारिज करें",
|
||||
"confidence_low": "निम्न",
|
||||
"confidence_medium": "मध्यम",
|
||||
"confidence_high": "उच्च",
|
||||
"recommended": "अनुशंसित",
|
||||
"seasonal_awareness": "मौसमी जागरूकता",
|
||||
"edit_seasonal_overrides": "मौसमी कारक संपादित करें",
|
||||
"seasonal_overrides_title": "मौसमी कारक (ओवरराइड)",
|
||||
"seasonal_overrides_hint": "प्रति माह कारक (0.1–5.0)। खाली = स्वचालित रूप से सीखा गया।",
|
||||
"seasonal_override_invalid": "अमान्य मान",
|
||||
"seasonal_override_range": "कारक 0.1 और 5.0 के बीच होना चाहिए",
|
||||
"clear_all": "सभी साफ़ करें",
|
||||
"seasonal_chart_title": "मौसमी कारक",
|
||||
"seasonal_learned": "सीखा गया",
|
||||
"seasonal_manual": "मैनुअल",
|
||||
"month_jan": "जन",
|
||||
"month_feb": "फ़र",
|
||||
"month_mar": "मार्च",
|
||||
"month_apr": "अप्रैल",
|
||||
"month_may": "मई",
|
||||
"month_jun": "जून",
|
||||
"month_jul": "जुल",
|
||||
"month_aug": "अग",
|
||||
"month_sep": "सित",
|
||||
"month_oct": "अक्टू",
|
||||
"month_nov": "नव",
|
||||
"month_dec": "दिस",
|
||||
"sensor_prediction": "सेंसर पूर्वानुमान",
|
||||
"degradation_trend": "रुझान",
|
||||
"trend_rising": "बढ़ता हुआ",
|
||||
"trend_falling": "घटता हुआ",
|
||||
"trend_stable": "स्थिर",
|
||||
"trend_insufficient_data": "अपर्याप्त डेटा",
|
||||
"days_until_threshold": "सीमा तक दिन",
|
||||
"threshold_exceeded": "सीमा पार हुई",
|
||||
"environmental_adjustment": "पर्यावरण कारक",
|
||||
"sensor_prediction_urgency": "सेंसर ~{days} दिनों में सीमा का अनुमान लगाता है",
|
||||
"day_short": "दिन",
|
||||
"weibull_reliability_curve": "विश्वसनीयता वक्र",
|
||||
"weibull_failure_probability": "विफलता संभावना",
|
||||
"weibull_r_squared": "फ़िट R²",
|
||||
"beta_early_failures": "प्रारंभिक विफलताएँ",
|
||||
"beta_random_failures": "यादृच्छिक विफलताएँ",
|
||||
"beta_wear_out": "घिसाव",
|
||||
"beta_highly_predictable": "अत्यधिक पूर्वानुमेय",
|
||||
"confidence_interval": "विश्वास अंतराल",
|
||||
"confidence_conservative": "रूढ़िवादी",
|
||||
"confidence_aggressive": "आशावादी",
|
||||
"current_interval_marker": "वर्तमान अंतराल",
|
||||
"recommended_marker": "अनुशंसित",
|
||||
"characteristic_life": "विशेषता आयु",
|
||||
"chart_mini_sparkline": "रुझान स्पार्कलाइन",
|
||||
"chart_history": "लागत और अवधि इतिहास",
|
||||
"chart_seasonal": "मौसमी कारक, 12 महीने",
|
||||
"chart_weibull": "वीबुल विश्वसनीयता वक्र",
|
||||
"chart_sparkline": "सेंसर ट्रिगर मान चार्ट",
|
||||
"days_progress": "दिनों की प्रगति",
|
||||
"qr_code": "QR कोड",
|
||||
"qr_generating": "QR कोड बनाया जा रहा है…",
|
||||
"qr_error": "QR कोड बनाने में विफल।",
|
||||
"qr_error_no_url": "कोई HA URL कॉन्फ़िगर नहीं है। कृपया सेटिंग्स → सिस्टम → नेटवर्क में एक बाहरी या आंतरिक URL सेट करें।",
|
||||
"save_error": "सहेजने में विफल। कृपया पुनः प्रयास करें।",
|
||||
"qr_print": "प्रिंट करें",
|
||||
"qr_download": "SVG डाउनलोड करें",
|
||||
"qr_action": "स्कैन पर क्रिया",
|
||||
"qr_action_view": "रखरखाव जानकारी देखें",
|
||||
"qr_action_complete": "रखरखाव को पूर्ण के रूप में चिह्नित करें",
|
||||
"qr_url_mode": "लिंक प्रकार",
|
||||
"qr_mode_companion": "Companion ऐप",
|
||||
"qr_mode_local": "लोकल (mDNS)",
|
||||
"qr_mode_server": "सर्वर URL",
|
||||
"overview": "अवलोकन",
|
||||
"analysis": "विश्लेषण",
|
||||
"recent_activities": "हाल की गतिविधियाँ",
|
||||
"search_notes": "टिप्पणियाँ खोजें",
|
||||
"avg_cost": "औसत लागत",
|
||||
"no_advanced_features": "कोई उन्नत सुविधा सक्षम नहीं",
|
||||
"no_advanced_features_hint": "यहाँ विश्लेषण डेटा देखने के लिए इंटीग्रेशन सेटिंग्स में “अनुकूली अंतराल” या “मौसमी पैटर्न” सक्षम करें।",
|
||||
"analysis_not_enough_data": "अभी विश्लेषण के लिए पर्याप्त डेटा नहीं।",
|
||||
"analysis_not_enough_data_hint": "वीबुल विश्लेषण के लिए कम से कम 5 पूर्ण रखरखाव आवश्यक हैं; मौसमी पैटर्न प्रति माह 6+ डेटा बिंदुओं के बाद दिखाई देते हैं।",
|
||||
"analysis_manual_task_hint": "अंतराल के बिना मैनुअल कार्य विश्लेषण डेटा उत्पन्न नहीं करते।",
|
||||
"completions": "पूर्णताएँ",
|
||||
"current": "वर्तमान",
|
||||
"shorter": "छोटा",
|
||||
"longer": "लंबा",
|
||||
"normal": "सामान्य",
|
||||
"disabled": "अक्षम",
|
||||
"compound_logic": "संयुक्त लॉजिक",
|
||||
"compound": "संयुक्त (कई शर्तें)",
|
||||
"compound_logic_and": "और — सभी शर्तें पूरी हों",
|
||||
"compound_logic_or": "या — कोई एक शर्त पर्याप्त",
|
||||
"compound_help": "कई सेंसर शर्तों को एक ट्रिगर में मिलाएँ।",
|
||||
"compound_no_conditions": "अभी कोई शर्त नहीं — कम से कम एक जोड़ें।",
|
||||
"compound_add_condition": "शर्त जोड़ें",
|
||||
"compound_condition": "शर्त",
|
||||
"compound_remove_condition": "शर्त हटाएँ",
|
||||
"card_title": "शीर्षक",
|
||||
"card_show_header": "सांख्यिकी के साथ हेडर दिखाएँ",
|
||||
"card_show_actions": "क्रिया बटन दिखाएँ",
|
||||
"card_compact": "संक्षिप्त मोड",
|
||||
"card_max_items": "अधिकतम आइटम (0 = सभी)",
|
||||
"card_filter_status": "स्थिति के अनुसार फ़िल्टर करें",
|
||||
"card_filter_status_help": "खाली = सभी स्थितियाँ दिखाएँ।",
|
||||
"card_filter_objects": "वस्तुओं के अनुसार फ़िल्टर करें",
|
||||
"card_filter_objects_help": "खाली = सभी वस्तुएँ दिखाएँ।",
|
||||
"card_filter_entities": "एंटिटी के अनुसार फ़िल्टर करें (entity_ids)",
|
||||
"card_filter_entities_help": "इस इंटीग्रेशन से sensor / binary_sensor एंटिटी चुनें। खाली = सभी।",
|
||||
"card_loading_objects": "वस्तुएँ लोड हो रही हैं…",
|
||||
"card_load_error": "वस्तुएँ लोड नहीं हो सकीं — WebSocket कनेक्शन जाँचें।",
|
||||
"card_no_tasks_title": "अभी कोई रखरखाव कार्य नहीं",
|
||||
"card_no_tasks_cta": "→ रखरखाव पैनल में एक बनाएँ",
|
||||
"no_objects": "अभी कोई वस्तु नहीं।",
|
||||
"action_error": "क्रिया विफल। कृपया पुनः प्रयास करें।",
|
||||
"area_id_optional": "क्षेत्र (वैकल्पिक)",
|
||||
"installation_date_optional": "स्थापना तिथि (वैकल्पिक)",
|
||||
"warranty_expiry_optional": "वारंटी समाप्ति (वैकल्पिक)",
|
||||
"warranty": "वारंटी",
|
||||
"warranty_valid_until": "{date} तक वैध",
|
||||
"warranty_expires_in": "{days} दिनों में समाप्त",
|
||||
"warranty_expired": "समाप्त",
|
||||
"cal_past_windows": "पिछली अवधियाँ",
|
||||
"cal_forward_windows": "आगामी अवधियाँ",
|
||||
"history_edit_title": "इतिहास प्रविष्टि संपादित करें",
|
||||
"history_edit_timestamp": "टाइमस्टैम्प",
|
||||
"manufacturer": "निर्माता",
|
||||
"model": "मॉडल",
|
||||
"area": "क्षेत्र",
|
||||
"actions": "क्रियाएँ",
|
||||
"view_mode_label": "दृश्य",
|
||||
"view_cards": "कार्ड दृश्य",
|
||||
"view_table": "तालिका दृश्य",
|
||||
"objects_table_columns_label": "वस्तु तालिका कॉलम",
|
||||
"objects_table_columns_hint": "चुनें कि वस्तु तालिका दृश्य में कौन से कॉलम दिखाई दें।",
|
||||
"custom_icon_optional": "आइकन (वैकल्पिक, उदा. mdi:wrench)",
|
||||
"task_enabled": "कार्य सक्षम",
|
||||
"skip_reason_prompt": "इस कार्य को छोड़ें?",
|
||||
"reason_optional": "कारण (वैकल्पिक)",
|
||||
"reset_date_prompt": "कार्य को किया गया के रूप में चिह्नित करें?",
|
||||
"reset_date_optional": "अंतिम बार किया गया तिथि (वैकल्पिक, डिफ़ॉल्ट आज)",
|
||||
"notes_label": "टिप्पणियाँ",
|
||||
"documentation_label": "दस्तावेज़",
|
||||
"no_nfc_tag": "— कोई टैग नहीं —",
|
||||
"dashboard": "डैशबोर्ड",
|
||||
"tab_today": "आज",
|
||||
"palette_placeholder": "ऑब्जेक्ट और कार्य खोजें…",
|
||||
"palette_no_results": "कोई मिलान नहीं",
|
||||
"palette_hint": "↑↓ नेविगेट · Enter खोलें · Esc बंद",
|
||||
"today_all_caught_up": "सब पूरा! इस सप्ताह कुछ भी बाकी नहीं।",
|
||||
"today_overdue": "अतिदेय",
|
||||
"today_due_today": "आज देय",
|
||||
"today_this_week": "इस सप्ताह",
|
||||
"settings": "सेटिंग्स",
|
||||
"settings_features": "उन्नत सुविधाएँ",
|
||||
"settings_features_desc": "उन्नत सुविधाओं को सक्षम या अक्षम करें। अक्षम करने से वे UI से छिप जाती हैं पर डेटा नहीं हटता।",
|
||||
"feat_adaptive": "अनुकूली निर्धारण",
|
||||
"feat_adaptive_desc": "रखरखाव इतिहास से इष्टतम अंतराल सीखें",
|
||||
"feat_predictions": "सेंसर पूर्वानुमान",
|
||||
"feat_predictions_desc": "सेंसर क्षरण से ट्रिगर तिथियों का अनुमान लगाएँ",
|
||||
"feat_seasonal": "मौसमी समायोजन",
|
||||
"feat_seasonal_desc": "मौसमी पैटर्न के आधार पर अंतराल समायोजित करें",
|
||||
"feat_environmental": "पर्यावरणीय सहसंबंध",
|
||||
"feat_environmental_desc": "तापमान/आर्द्रता के साथ अंतराल का सहसंबंध करें",
|
||||
"feat_budget": "बजट ट्रैकिंग",
|
||||
"feat_budget_desc": "मासिक और वार्षिक रखरखाव खर्च ट्रैक करें",
|
||||
"feat_groups": "कार्य समूह",
|
||||
"feat_groups_desc": "कार्यों को तार्किक समूहों में व्यवस्थित करें",
|
||||
"feat_checklists": "चेकलिस्ट",
|
||||
"feat_checklists_desc": "कार्य पूर्णता के लिए बहु-चरणीय प्रक्रियाएँ",
|
||||
"settings_general": "सामान्य",
|
||||
"settings_default_warning": "डिफ़ॉल्ट चेतावनी दिन",
|
||||
"settings_panel_enabled": "साइडबार पैनल",
|
||||
"settings_panel_title": "साइडबार पैनल शीर्षक",
|
||||
"settings_notifications": "सूचनाएँ",
|
||||
"settings_notify_service": "सूचना सेवा",
|
||||
"test_notification": "परीक्षण सूचना",
|
||||
"send_test": "परीक्षण भेजें",
|
||||
"testing": "भेजा जा रहा है…",
|
||||
"test_notification_success": "परीक्षण सूचना भेजी गई",
|
||||
"test_notification_failed": "परीक्षण सूचना विफल",
|
||||
"settings_notify_due_soon": "जल्द देय होने पर सूचित करें",
|
||||
"settings_notify_overdue": "अतिदेय होने पर सूचित करें",
|
||||
"settings_notify_triggered": "ट्रिगर होने पर सूचित करें",
|
||||
"settings_interval_hours": "दोहराव अंतराल (घंटे, 0 = एक बार)",
|
||||
"settings_quiet_hours": "शांत घंटे",
|
||||
"settings_quiet_start": "प्रारंभ",
|
||||
"settings_quiet_end": "समाप्त",
|
||||
"settings_max_per_day": "प्रति दिन अधिकतम सूचनाएँ (0 = असीमित)",
|
||||
"settings_bundling": "सूचनाएँ बंडल करें",
|
||||
"settings_bundle_threshold": "बंडल सीमा",
|
||||
"settings_reminder_leads": "Extra reminders (days before due)",
|
||||
"settings_reminder_leads_hint": "Comma-separated lead times, e.g. 14, 3, 0 — one extra reminder fires on each matching day. Empty = off.",
|
||||
"settings_actions": "मोबाइल क्रिया बटन",
|
||||
"settings_action_complete": "'पूर्ण करें' बटन दिखाएँ",
|
||||
"settings_action_skip": "'छोड़ें' बटन दिखाएँ",
|
||||
"settings_action_snooze": "'स्नूज़' बटन दिखाएँ",
|
||||
"settings_weekly_digest": "साप्ताहिक सारांश",
|
||||
"settings_weekly_digest_hint": "सोमवार सुबह एक सारांश सूचना जब कार्य देय हों।",
|
||||
"settings_warranty_reminder": "Warranty expiry reminder",
|
||||
"settings_warranty_reminder_days": "Days before expiry",
|
||||
"settings_warranty_reminder_hint": "Notify once when an object's warranty is this many days from expiring.",
|
||||
"settings_snooze_hours": "स्नूज़ अवधि (घंटे)",
|
||||
"settings_budget": "बजट",
|
||||
"settings_currency": "मुद्रा",
|
||||
"settings_budget_monthly": "मासिक बजट",
|
||||
"settings_budget_yearly": "वार्षिक बजट",
|
||||
"settings_budget_alerts": "बजट अलर्ट",
|
||||
"settings_budget_threshold": "अलर्ट सीमा (%)",
|
||||
"settings_import_export": "आयात / निर्यात",
|
||||
"settings_export_json": "JSON निर्यात करें",
|
||||
"settings_export_yaml": "YAML निर्यात करें",
|
||||
"settings_export_csv": "CSV निर्यात करें",
|
||||
"settings_import_csv": "CSV आयात करें",
|
||||
"settings_import_placeholder": "JSON या CSV सामग्री यहाँ चिपकाएँ…",
|
||||
"settings_import_btn": "आयात करें",
|
||||
"settings_import_success": "{count} वस्तुएँ सफलतापूर्वक आयात की गईं।",
|
||||
"settings_export_success": "निर्यात डाउनलोड किया गया।",
|
||||
"settings_saved": "सेटिंग सहेजी गई।",
|
||||
"settings_include_history": "इतिहास शामिल करें",
|
||||
"sort_alphabetical": "वर्णानुक्रम",
|
||||
"sort_due_soonest": "सबसे जल्द देय",
|
||||
"sort_task_count": "कार्य संख्या",
|
||||
"sort_area": "क्षेत्र",
|
||||
"sort_assigned_user": "असाइन किया गया उपयोगकर्ता",
|
||||
"sort_group": "समूह",
|
||||
"groupby_none": "कोई समूहन नहीं",
|
||||
"groupby_area": "क्षेत्र के अनुसार",
|
||||
"groupby_group": "समूह के अनुसार",
|
||||
"groupby_user": "उपयोगकर्ता के अनुसार",
|
||||
"filter_label": "फ़िल्टर",
|
||||
"user_label": "उपयोगकर्ता",
|
||||
"sort_label": "क्रमबद्ध करें",
|
||||
"group_by_label": "इसके अनुसार समूहित करें",
|
||||
"state_value_help": "HA स्थिति मान का उपयोग करें (आमतौर पर लोअरकेस, उदा. \"on\"/\"off\")। सहेजने पर केस सामान्यीकृत हो जाता है।",
|
||||
"target_changes_help": "ट्रिगर होने से पहले मिलान वाले संक्रमणों की संख्या (डिफ़ॉल्ट: 1)।",
|
||||
"qr_print_title": "QR कोड प्रिंट करें",
|
||||
"qr_print_desc": "अपने उपकरण पर काटकर चिपकाने के लिए QR कोड का प्रिंट-योग्य पृष्ठ बनाएँ।",
|
||||
"qr_print_load": "वस्तुएँ लोड करें",
|
||||
"qr_print_filter": "फ़िल्टर",
|
||||
"qr_print_objects": "वस्तुएँ",
|
||||
"qr_print_actions": "क्रियाएँ",
|
||||
"qr_print_url_mode": "लिंक प्रकार",
|
||||
"qr_print_estimate": "अनुमानित QR कोड",
|
||||
"qr_print_over_limit": "सीमा 200 है, फ़िल्टर को संकीर्ण करें",
|
||||
"qr_print_generate": "QR कोड बनाएँ",
|
||||
"qr_print_generating": "बनाया जा रहा है…",
|
||||
"qr_print_ready": "QR कोड तैयार",
|
||||
"qr_print_print_button": "प्रिंट करें",
|
||||
"qr_print_empty": "बनाने के लिए कुछ नहीं",
|
||||
"qr_action_skip": "छोड़ें",
|
||||
"vacation_title": "अवकाश मोड",
|
||||
"vacation_active": "सक्रिय",
|
||||
"vacation_ended": "समाप्त",
|
||||
"vacation_desc": "अवकाश की योजना बनाएँ: इस अवधि और कुछ बफ़र दिनों के दौरान सूचनाएँ रुकी रहती हैं। आप विशिष्ट कार्यों को वापस शामिल कर सकते हैं।",
|
||||
"vacation_enable": "अवकाश मोड सक्षम करें",
|
||||
"vacation_start": "प्रारंभ",
|
||||
"vacation_end": "समाप्त",
|
||||
"vacation_buffer": "बफ़र (दिन)",
|
||||
"vacation_exempt_title": "अवकाश के दौरान फिर भी सूचित करें",
|
||||
"vacation_exempt_desc": "ऐसे कार्य चुनें जिनकी अवकाश के दौरान भी सूचना मिलनी चाहिए (उदा. महत्वपूर्ण पूल रसायन)।",
|
||||
"vacation_load_tasks": "कार्य लोड करें",
|
||||
"vacation_preview_btn": "पूर्वावलोकन दिखाएँ",
|
||||
"vacation_preview_affected": "प्रभावित कार्य",
|
||||
"vacation_event_due_soon": "जल्द देय हो जाता है",
|
||||
"vacation_event_overdue": "अतिदेय हो जाता है",
|
||||
"vacation_event_triggered_est": "सेंसर ट्रिगर संभव",
|
||||
"vacation_sensor_based": "(सेंसर-आधारित)",
|
||||
"vacation_action_notify": "फिर भी सूचित करें",
|
||||
"vacation_action_unsilence": "फिर से चुप करें",
|
||||
"vacation_marked_complete": "पूर्ण के रूप में चिह्नित",
|
||||
"vacation_marked_skip": "छोड़ा गया",
|
||||
"vacation_end_now": "अवकाश अभी समाप्त करें",
|
||||
"add": "जोड़ें",
|
||||
"show_stats": "सांख्यिकी + ग्राफ़ दिखाएँ",
|
||||
"hide_stats": "सांख्यिकी छिपाएँ",
|
||||
"adaptive_no_data": "अनुकूली विश्लेषण के लिए अभी पर्याप्त पूर्णता इतिहास नहीं। अंतराल अनुशंसाएँ और विश्वसनीयता चार्ट अनलॉक करने के लिए इस कार्य को कुछ और बार पूर्ण करें।",
|
||||
"suggestion_applied": "सुझाया गया अंतराल लागू किया गया",
|
||||
"vacation_mode": "अवकाश मोड",
|
||||
"vacation_status_active": "अभी सक्रिय",
|
||||
"vacation_status_scheduled": "निर्धारित",
|
||||
"vacation_status_inactive": "निष्क्रिय",
|
||||
"vacation_end_now_confirm": "अवकाश तुरंत समाप्त करें?",
|
||||
"vacation_exempt_count": "छूट प्राप्त",
|
||||
"vacation_advanced": "उन्नत…",
|
||||
"vacation_open_panel": "पैनल में खोलें",
|
||||
"enable": "सक्षम करें",
|
||||
"saved": "सहेजा गया",
|
||||
"budget_monthly_set": "मासिक सेट करें",
|
||||
"budget_yearly_set": "वार्षिक सेट करें",
|
||||
"budget_advanced": "मुद्रा, अलर्ट…",
|
||||
"budget_open_panel": "पैनल में खोलें",
|
||||
"groups_empty": "अभी कोई समूह नहीं।",
|
||||
"group_new_placeholder": "समूह जोड़ें…",
|
||||
"group_delete_confirm": "समूह \"{name}\" हटाएँ?",
|
||||
"groups_manage_tasks": "कार्य असाइनमेंट प्रबंधित करें…",
|
||||
"groups_open_panel": "पैनल में खोलें",
|
||||
"unassigned": "असाइन नहीं",
|
||||
"no_area": "कोई क्षेत्र नहीं",
|
||||
"has_overdue": "अतिदेय कार्य हैं",
|
||||
"object": "वस्तु",
|
||||
"settings_panel_access": "पैनल पहुँच",
|
||||
"settings_panel_access_desc": "व्यवस्थापकों को हमेशा पूर्ण पहुँच होती है। विशिष्ट गैर-व्यवस्थापकों को बनाना, संपादित करना और हटाना सौंपने के लिए, इसे चालू करें और नीचे उन्हें चुनें — बाकी सभी को केवल पूर्ण करें और छोड़ें दिखता है।",
|
||||
"settings_operator_write": "चयनित उपयोगकर्ताओं को बनाने, संपादित करने और हटाने की अनुमति दें",
|
||||
"settings_operator_write_desc": "बंद: केवल व्यवस्थापक सामग्री बदल सकते हैं। चालू: नीचे चयनित उपयोगकर्ताओं को भी पूर्ण पहुँच मिलती है।",
|
||||
"no_non_admin_users": "कोई गैर-व्यवस्थापक उपयोगकर्ता नहीं मिला। सेटिंग्स → लोग में कुछ जोड़ें।",
|
||||
"owner_label": "स्वामी",
|
||||
"feat_completion_actions": "पूर्णता क्रियाएँ",
|
||||
"feat_completion_actions_desc": "पूर्ण होने पर प्रति-कार्य HA क्रिया + पूर्व-निर्धारित मानों के साथ त्वरित-पूर्ण QR।",
|
||||
"on_complete_action_title": "पूर्ण होने पर: HA क्रिया ट्रिगर करें (वैकल्पिक)",
|
||||
"on_complete_action_desc": "कार्य पूर्ण होने पर एक HA सेवा कॉल करता है — उदा. डिवाइस पर काउंटर रीसेट करना।",
|
||||
"on_complete_action_service": "सेवा",
|
||||
"on_complete_action_target": "लक्ष्य एंटिटी",
|
||||
"on_complete_action_target_hint": "ध्यान दें: एंटिटी डोमेन सेवा से मेल खाना चाहिए — उदा. 'button.press' केवल button.* पर काम करता है, 'counter.increment' केवल counter.* पर, 'input_button.press' केवल input_button.* पर आदि। मेल न खाने पर क्रिया चुपचाप विफल हो जाएगी (HA लॉग करता है 'Referenced entities ... missing or not currently available')।",
|
||||
"on_complete_action_data": "डेटा (JSON, वैकल्पिक)",
|
||||
"on_complete_action_test": "कॉन्फ़िगरेशन सत्यापित करें",
|
||||
"on_complete_action_test_success": "✓ कॉन्फ़िगरेशन वैध (क्रिया केवल कार्य पूर्ण होने पर चलेगी)",
|
||||
"on_complete_action_test_failed": "विफल",
|
||||
"quick_complete_defaults_title": "त्वरित-पूर्ण डिफ़ॉल्ट (QR स्कैन के लिए, वैकल्पिक)",
|
||||
"quick_complete_defaults_desc": "त्वरित-पूर्ण QR स्कैन के लिए पूर्व-निर्धारित मान। इनके बिना, QR पूर्ण करें संवाद खोलता है।",
|
||||
"quick_complete_defaults_notes": "टिप्पणियाँ",
|
||||
"quick_complete_defaults_cost": "लागत",
|
||||
"quick_complete_defaults_duration": "अवधि (मिनट)",
|
||||
"quick_complete_defaults_feedback_none": "कोई प्रतिक्रिया नहीं",
|
||||
"quick_complete_defaults_feedback_needed": "आवश्यक था",
|
||||
"quick_complete_defaults_feedback_not_needed": "आवश्यक नहीं",
|
||||
"quick_complete_success": "त्वरित रूप से पूर्ण के रूप में चिह्नित",
|
||||
"show_all_objects": "सभी वस्तुएँ दिखाएँ",
|
||||
"show_all_tasks": "फ़िल्टर हटाएँ — सभी कार्य दिखाएँ",
|
||||
"filter_to_overdue": "कार्य सूची को केवल अतिदेय तक फ़िल्टर करें",
|
||||
"filter_to_due_soon": "कार्य सूची को केवल जल्द-देय तक फ़िल्टर करें",
|
||||
"filter_to_triggered": "कार्य सूची को केवल ट्रिगर हुए तक फ़िल्टर करें",
|
||||
"open_task": "कार्य खोलें",
|
||||
"show_details": "इतिहास + सांख्यिकी दिखाएँ",
|
||||
"hide_details": "विवरण छिपाएँ",
|
||||
"history_empty": "अभी कोई इतिहास नहीं।",
|
||||
"history_edit_button": "प्रविष्टि संपादित करें",
|
||||
"total_cost": "कुल लागत",
|
||||
"times_performed": "किया गया",
|
||||
"older_entries": "पुराने",
|
||||
"open_in_panel": "रखरखाव पैनल में खोलें",
|
||||
"skip_reason": "छोड़ने का कारण (वैकल्पिक)",
|
||||
"reset_to_date": "अंतिम बार किया गया को रीसेट करें",
|
||||
"delete_task_confirm": "इस कार्य और इसके इतिहास को हटाएँ?",
|
||||
"delete_object_confirm": "इस वस्तु और इसके सभी कार्यों को हटाएँ?",
|
||||
"loading": "लोड हो रहा है…",
|
||||
"archive": "संग्रह करें",
|
||||
"undo": "पूर्ववत करें",
|
||||
"task_archived": "कार्य संग्रहित",
|
||||
"object_archived": "ऑब्जेक्ट संग्रहित",
|
||||
"unarchive": "संग्रह से हटाएँ",
|
||||
"archived": "संग्रहीत",
|
||||
"show_archived": "संग्रहीत दिखाएँ",
|
||||
"hide_archived": "संग्रहीत छिपाएँ",
|
||||
"confirm_archive_object": "इस ऑब्जेक्ट और इसके कार्यों को संग्रहित करें? इतिहास सुरक्षित रहता है और बाद में पुनर्स्थापित किया जा सकता है।",
|
||||
"settings_archive": "संग्रह और प्रतिधारण",
|
||||
"settings_archive_desc": "पूर्ण एक-बार के कार्यों को हटाए बिना अलग रखें। संग्रहीत आइटम छिपे और निष्क्रिय रहते हैं, पर इतिहास और लागत बनाए रखते हैं।",
|
||||
"settings_archive_oneoff_days": "पूर्ण एक-बार के कार्य स्वतः संग्रहित करें (दिन, 0 = बंद)",
|
||||
"settings_delete_archived_oneoff_days": "संग्रहीत एक-बार के कार्य स्वतः हटाएँ (दिन, 0 = कभी नहीं)",
|
||||
"archive_object": "ऑब्जेक्ट संग्रहित करें",
|
||||
"unarchive_object": "ऑब्जेक्ट संग्रह से हटाएँ",
|
||||
"documents": "दस्तावेज़",
|
||||
"documents_empty": "अभी तक कोई दस्तावेज़ नहीं।",
|
||||
"doc_upload": "फ़ाइल अपलोड करें",
|
||||
"doc_uploading": "अपलोड हो रहा है…",
|
||||
"doc_add_link": "लिंक जोड़ें",
|
||||
"doc_link_url": "URL (https://…)",
|
||||
"doc_link_title": "शीर्षक (वैकल्पिक)",
|
||||
"doc_open": "खोलें",
|
||||
"doc_delete_confirm": "\"{name}\" हटाएँ?",
|
||||
"doc_too_large": "फ़ाइल बहुत बड़ी है (अधिकतम 25 MB)।",
|
||||
"doc_upload_failed": "अपलोड विफल रहा।",
|
||||
"completion_photo_optional": "Completion photo (optional)",
|
||||
"add_photo": "Add photo",
|
||||
"uploading": "Uploading…",
|
||||
"remove": "Remove",
|
||||
"doc_deduped": "पहले से कहीं और संग्रहीत — साझा, कोई अतिरिक्त स्थान नहीं।",
|
||||
"doc_dup_in_object": "यह फ़ाइल पहले से इस ऑब्जेक्ट से जुड़ी है।",
|
||||
"doc_link_invalid": "केवल http/https लिंक की अनुमति है।",
|
||||
"doc_cat_manual": "मैनुअल",
|
||||
"doc_cat_warranty": "वारंटी",
|
||||
"doc_cat_invoice": "चालान",
|
||||
"doc_cat_spare_parts": "स्पेयर पार्ट्स",
|
||||
"doc_cat_photo": "फ़ोटो",
|
||||
"doc_cat_other": "अन्य",
|
||||
"doc_link_badge": "लिंक",
|
||||
"doc_storage_title": "दस्तावेज़ संग्रहण",
|
||||
"doc_storage_saved": "डीडुप्लिकेशन से बचत",
|
||||
"doc_storage_refresh": "ताज़ा करें",
|
||||
"doc_download": "डाउनलोड करें",
|
||||
"doc_close": "बंद करें",
|
||||
"doc_camera": "फ़ोटो लें",
|
||||
"doc_drop_hint": "फ़ाइलें यहाँ छोड़ें",
|
||||
"doc_task_none": "इस कार्य से कोई दस्तावेज़ संबद्ध नहीं है।",
|
||||
"doc_link_existing": "दस्तावेज़ जोड़ें…",
|
||||
"doc_attach": "जोड़ें",
|
||||
"doc_unlink": "अलग करें",
|
||||
"doc_page": "पृष्ठ",
|
||||
"chart_range_7d": "7दिन",
|
||||
"chart_range_30d": "30दिन",
|
||||
"chart_range_90d": "90दिन",
|
||||
"chart_range_1y": "1वर्ष",
|
||||
"chart_since_service": "पिछले रखरखाव के बाद से",
|
||||
"chart_no_stats": "इस एंटिटी के लिए दीर्घकालिक आँकड़े नहीं हैं — केवल रखरखाव इवेंट मान दिखाए जा रहे हैं",
|
||||
"auto_complete_on_recovery": "सेंसर ठीक होने पर स्वतः पूर्ण करें",
|
||||
"auto_complete_on_recovery_help": "जब ट्रिगर स्वयं हल हो जाता है तो एक पूर्णता दर्ज करता है (अंतिम निष्पादन सेट करता है) — जैसे नमक भरा गया, फ़िल्टर बदला गया।",
|
||||
"doc_search": "दस्तावेज़ खोजें…",
|
||||
"doc_search_none": "कोई मेल खाता दस्तावेज़ नहीं",
|
||||
"link_device_optional": "मौजूदा डिवाइस से लिंक करें (वैकल्पिक)",
|
||||
"parent_object_optional": "मूल ऑब्जेक्ट (वैकल्पिक)",
|
||||
"parent_none": "(कोई मूल नहीं)",
|
||||
"paused": "रोका गया",
|
||||
"pause_object": "रोकें",
|
||||
"resume_object": "फिर से शुरू करें",
|
||||
"pause_until_prompt": "इस ऑब्जेक्ट के शेड्यूल फ़्रीज़ करें — फिर से शुरू करने तक कुछ भी देय नहीं होगा और कोई सूचना नहीं आएगी। वैकल्पिक रूप से स्वतः पुनरारंभ की तारीख़ सेट करें।",
|
||||
"pause_until_label": "फिर से शुरू करें (वैकल्पिक)",
|
||||
"object_paused": "ऑब्जेक्ट रोका गया",
|
||||
"object_resumed": "ऑब्जेक्ट फिर से शुरू — शेड्यूल पुनः आरंभ",
|
||||
"object_paused_badge": "रोका गया",
|
||||
"paused_until_label": "तक",
|
||||
"replace_object": "बदलें…",
|
||||
"replace_object_prompt": "इस ऑब्जेक्ट को सेवानिवृत्त करें और उत्तराधिकारी बनाएं। इतिहास और लागत पुराने पर संग्रहीत रहती है; कार्य और दस्तावेज़ नए में चले जाते हैं, काउंटर नए सिरे से शुरू होते हैं।",
|
||||
"replace_name_label": "उत्तराधिकारी का नाम",
|
||||
"object_replaced": "ऑब्जेक्ट बदला गया — उत्तराधिकारी बनाया गया",
|
||||
"reading_unit_label": "रीडिंग इकाई (जैसे kWh, m³)",
|
||||
"reading_unit_help": "इस कार्य को पूरा करते समय दर्ज मान के बगल में दिखाई जाती है।",
|
||||
"reading_value_label": "रीडिंग मान",
|
||||
"reading_label": "रीडिंग",
|
||||
"settings_templates_label": "टेम्पलेट गैलरी",
|
||||
"settings_templates_hint": "जिन टेम्पलेट की कभी ज़रूरत नहीं होगी उन्हें अनचेक करें — वे \"टेम्पलेट से\" चयनकर्ताओं (पैनल और कॉन्फ़िग फ़्लो) से गायब हो जाएंगे। और कुछ नहीं बदलता; कभी भी फिर से सक्षम कर सकते हैं।",
|
||||
"worksheet": "कार्य पत्रक",
|
||||
"worksheet_scan_view": "कार्य खोलने के लिए स्कैन करें",
|
||||
"worksheet_scan_complete": "पूरा करने के लिए स्कैन करें",
|
||||
"worksheet_manual_excerpt": "मैनुअल अंश",
|
||||
"worksheet_pages": "पृष्ठ",
|
||||
"worksheet_printed": "मुद्रित",
|
||||
"worksheet_never": "कभी नहीं"
|
||||
}
|
||||
@@ -0,0 +1,696 @@
|
||||
{
|
||||
"maintenance": "Manutenzione",
|
||||
"objects": "Oggetti",
|
||||
"tasks": "Attività",
|
||||
"overdue": "Scaduto",
|
||||
"due_soon": "In scadenza",
|
||||
"triggered": "Attivato",
|
||||
"ok": "OK",
|
||||
"all": "Tutti",
|
||||
"new_object": "+ Nuovo oggetto",
|
||||
"templates_from": "Da modello",
|
||||
"templates_title": "Inizia da un modello",
|
||||
"templates_task_count": "{n} attività",
|
||||
"template_created": "Creato da modello",
|
||||
"onboard_hint": "Aggiungi il primo oggetto per iniziare a tracciare la manutenzione.",
|
||||
"edit": "Modifica",
|
||||
"duplicate": "Duplica",
|
||||
"task_duplicated": "Attività duplicata",
|
||||
"object_duplicated": "Oggetto duplicato",
|
||||
"delete": "Elimina",
|
||||
"add_task": "+ Attività",
|
||||
"complete": "Completato",
|
||||
"completed": "Completato",
|
||||
"skip": "Salta",
|
||||
"skipped": "Saltato",
|
||||
"missed": "Missed",
|
||||
"reset": "Reimposta",
|
||||
"snooze": "Snooze",
|
||||
"snoozed": "Snoozed",
|
||||
"cancel": "Annulla",
|
||||
"bulk_select": "Seleziona",
|
||||
"bulk_select_all": "Seleziona tutto",
|
||||
"bulk_n_selected": "{n} selezionati",
|
||||
"bulk_completed": "{n} attività completate",
|
||||
"bulk_archived": "{n} attività archiviate",
|
||||
"completing": "Completamento…",
|
||||
"interval": "Intervallo",
|
||||
"warning": "Avviso",
|
||||
"last_performed": "Ultima esecuzione",
|
||||
"next_due": "Prossima scadenza",
|
||||
"days_until_due": "Giorni alla scadenza",
|
||||
"avg_duration": "Ø Durata",
|
||||
"trigger": "Trigger",
|
||||
"trigger_type": "Tipo di trigger",
|
||||
"threshold_above": "Limite superiore",
|
||||
"threshold_below": "Limite inferiore",
|
||||
"threshold": "Soglia",
|
||||
"counter": "Contatore",
|
||||
"state_change": "Cambio di stato",
|
||||
"runtime": "Tempo di funzionamento",
|
||||
"runtime_hours": "Durata obiettivo (ore)",
|
||||
"target_value": "Valore obiettivo",
|
||||
"baseline": "Linea di base",
|
||||
"target_changes": "Modifiche obiettivo",
|
||||
"for_minutes": "Per (minuti)",
|
||||
"time_based": "Temporale",
|
||||
"sensor_based": "Sensore",
|
||||
"manual": "Manuale",
|
||||
"one_time": "Una tantum",
|
||||
"weekdays": "Giorni della settimana",
|
||||
"nth_weekday": "N-esimo giorno della settimana del mese",
|
||||
"day_of_month": "Giorno del mese",
|
||||
"recurrence_on_days": "Ripeti il",
|
||||
"recurrence_occurrence": "Occorrenza",
|
||||
"recurrence_weekday": "Giorno della settimana",
|
||||
"recurrence_day": "Giorno del mese (1–31)",
|
||||
"recurrence_last_day": "Last day of the month",
|
||||
"recurrence_business_day": "Business days only (roll back from weekend)",
|
||||
"recurrence_offset": "Offset (days, ±)",
|
||||
"recurrence_offset_help": "Shift the date by ±N days, e.g. -2 = two days before.",
|
||||
"last_day_month": "Last day of month",
|
||||
"last_business_day_month": "Last business day",
|
||||
"ord_1": "1º",
|
||||
"ord_2": "2º",
|
||||
"ord_3": "3º",
|
||||
"ord_4": "4º",
|
||||
"ord_5": "5º",
|
||||
"ord_last": "Ultimo",
|
||||
"day_word": "Giorno",
|
||||
"interval_value": "Intervallo",
|
||||
"interval_unit": "Unità",
|
||||
"unit_days": "Giorni",
|
||||
"unit_weeks": "Settimane",
|
||||
"unit_months": "Mesi",
|
||||
"unit_years": "Anni",
|
||||
"due_date": "Data di scadenza",
|
||||
"cleaning": "Pulizia",
|
||||
"inspection": "Ispezione",
|
||||
"replacement": "Sostituzione",
|
||||
"calibration": "Calibrazione",
|
||||
"service": "Servizio",
|
||||
"reading": "Lettura",
|
||||
"custom": "Personalizzato",
|
||||
"history": "Cronologia",
|
||||
"cost": "Costo",
|
||||
"report_button": "Report",
|
||||
"report_title": "Report di manutenzione",
|
||||
"report_generated": "Generato",
|
||||
"report_times_done": "Fatto",
|
||||
"report_total_cost": "Costo totale",
|
||||
"report_every": "ogni {n} {unit}",
|
||||
"report_notes": "Note",
|
||||
"report_col_type": "Tipo",
|
||||
"report_col_status": "Stato",
|
||||
"report_col_schedule": "Pianificazione",
|
||||
"duration": "Durata",
|
||||
"both": "Entrambi",
|
||||
"trigger_val": "Valore trigger",
|
||||
"complete_title": "Completato: ",
|
||||
"checklist": "Checklist",
|
||||
"checklist_steps_optional": "Passaggi della checklist (opzionale)",
|
||||
"checklist_placeholder": "Pulire il filtro\nSostituire la guarnizione\nTestare la pressione",
|
||||
"checklist_help": "Un passaggio per riga. Max 100 elementi.",
|
||||
"err_too_long": "{field}: troppo lungo (max {n} caratteri)",
|
||||
"err_too_short": "{field}: troppo corto (min {n} caratteri)",
|
||||
"err_value_too_high": "{field}: troppo grande (max {n})",
|
||||
"err_value_too_low": "{field}: troppo piccolo (min {n})",
|
||||
"err_required": "{field}: campo obbligatorio",
|
||||
"err_wrong_type": "{field}: tipo errato (atteso: {type})",
|
||||
"err_invalid_choice": "{field}: valore non consentito",
|
||||
"err_invalid_value": "{field}: valore non valido",
|
||||
"feat_schedule_time": "Pianificazione oraria",
|
||||
"feat_schedule_time_desc": "Le attività scadono a un'ora specifica anziché a mezzanotte.",
|
||||
"schedule_time_optional": "Scadenza all'ora (opzionale, HH:MM)",
|
||||
"schedule_time_help": "Vuoto = mezzanotte (default). Fuso orario HA.",
|
||||
"at_time": "alle",
|
||||
"notes_optional": "Note (opzionale)",
|
||||
"cost_optional": "Costo (opzionale)",
|
||||
"duration_minutes": "Durata in minuti (opzionale)",
|
||||
"days": "giorni",
|
||||
"day": "giorno",
|
||||
"today": "Oggi",
|
||||
"d_overdue": "g in ritardo",
|
||||
"no_tasks": "Nessuna attività di manutenzione. Crea un oggetto per iniziare.",
|
||||
"no_tasks_short": "Nessuna attività",
|
||||
"no_history": "Nessuna voce nella cronologia.",
|
||||
"show_all": "Mostra tutto",
|
||||
"cost_duration_chart": "Costi & Durata",
|
||||
"installed": "Installato",
|
||||
"confirm_delete_object": "Eliminare questo oggetto e tutte le sue attività?",
|
||||
"confirm_delete_task": "Eliminare questa attività?",
|
||||
"min": "Min",
|
||||
"max": "Max",
|
||||
"save": "Salva",
|
||||
"saving": "Salvataggio…",
|
||||
"edit_task": "Modifica attività",
|
||||
"new_task": "Nuova attività di manutenzione",
|
||||
"task_name": "Nome attività",
|
||||
"maintenance_type": "Tipo di manutenzione",
|
||||
"priority": "Priorità",
|
||||
"labels": "Labels",
|
||||
"labels_placeholder": "e.g. safety, seasonal, tenant-visible",
|
||||
"labels_help": "Comma-separated tags for filtering and reporting.",
|
||||
"priority_low": "Bassa",
|
||||
"priority_normal": "Normale",
|
||||
"priority_high": "Alta",
|
||||
"schedule_type": "Tipo di pianificazione",
|
||||
"interval_days": "Intervallo (giorni)",
|
||||
"warning_days": "Giorni di avviso",
|
||||
"earliest_completion_days": "Earliest completion (days before due)",
|
||||
"earliest_completion_days_help": "Leave empty to allow completing any time. 0 = only on/after the due date.",
|
||||
"last_performed_optional": "Ultima esecuzione (opzionale)",
|
||||
"interval_anchor": "Ancoraggio intervallo",
|
||||
"anchor_completion": "Dalla data di completamento",
|
||||
"anchor_planned": "Dalla data pianificata (nessuna deriva)",
|
||||
"edit_object": "Modifica oggetto",
|
||||
"name": "Nome",
|
||||
"manufacturer_optional": "Produttore (opzionale)",
|
||||
"model_optional": "Modello (opzionale)",
|
||||
"serial_number_optional": "Numero di serie (opzionale)",
|
||||
"serial_number_label": "N/S",
|
||||
"documentation_url_label": "Manuale",
|
||||
"object_notes_label": "Note",
|
||||
"sort_due_date": "Scadenza",
|
||||
"sort_object": "Nome oggetto",
|
||||
"sort_type": "Tipo",
|
||||
"sort_task_name": "Nome attività",
|
||||
"all_objects": "Tutti gli oggetti",
|
||||
"tasks_lower": "attività",
|
||||
"no_tasks_yet": "Nessuna attività",
|
||||
"add_first_task": "Aggiungi prima attività",
|
||||
"trigger_configuration": "Configurazione trigger",
|
||||
"entity_id": "ID entità",
|
||||
"comma_separated": "separati da virgola",
|
||||
"entity_logic": "Logica entità",
|
||||
"entity_logic_any": "Qualsiasi entità attiva",
|
||||
"entity_logic_all": "Tutte le entità devono attivare",
|
||||
"entities": "entità",
|
||||
"attribute_optional": "Attributo (opzionale, vuoto = stato)",
|
||||
"use_entity_state": "Usa stato dell'entità (nessun attributo)",
|
||||
"trigger_above": "Attivare sopra",
|
||||
"trigger_below": "Attivare sotto",
|
||||
"for_at_least_minutes": "Per almeno (minuti)",
|
||||
"safety_interval_days": "Intervallo di sicurezza (giorni, opzionale)",
|
||||
"safety_interval": "Intervallo di sicurezza (opzionale)",
|
||||
"delta_mode": "Modalità delta",
|
||||
"from_state_optional": "Dallo stato (opzionale)",
|
||||
"to_state_optional": "Allo stato (opzionale)",
|
||||
"documentation_url_optional": "URL documentazione (opzionale)",
|
||||
"object_notes_optional": "Note (opzionale)",
|
||||
"nfc_tag_id_optional": "ID tag NFC (opzionale)",
|
||||
"nfc_tags_empty_help": "Nessun tag NFC ancora registrato in Home Assistant.",
|
||||
"nfc_tags_open_settings": "Apri impostazioni tag",
|
||||
"nfc_tags_refresh": "Aggiorna",
|
||||
"environmental_entity_optional": "Sensore ambientale (opzionale)",
|
||||
"environmental_entity_helper": "es. sensor.temperatura_esterna — regola l'intervallo in base alle condizioni ambientali",
|
||||
"environmental_attribute_optional": "Attributo ambientale (opzionale)",
|
||||
"nfc_tag_id": "ID tag NFC",
|
||||
"nfc_linked": "Tag NFC collegato",
|
||||
"nfc_link_hint": "Clicca per collegare un tag NFC",
|
||||
"responsible_user": "Utente responsabile",
|
||||
"shared_with": "Shared with (rotation)",
|
||||
"shared_with_help": "Pick multiple people to share this task; the responsible person rotates on each completion.",
|
||||
"rotation_strategy": "Rotation",
|
||||
"rotation_none": "No rotation",
|
||||
"rotation_round_robin": "Round-robin",
|
||||
"rotation_least_completed": "Least completed",
|
||||
"rotation_random": "Random",
|
||||
"no_user_assigned": "(Nessun utente assegnato)",
|
||||
"all_users": "Tutti gli utenti",
|
||||
"my_tasks": "Le mie attività",
|
||||
"tab_calendar": "Calendario",
|
||||
"cal_no_events": "Nessuna manutenzione",
|
||||
"cal_window_7": "7 giorni",
|
||||
"cal_window_14": "14 giorni",
|
||||
"cal_window_30": "30 giorni",
|
||||
"cal_window_365": "1 anno",
|
||||
"cal_every_n_days": "ogni {n} giorni",
|
||||
"cal_source_time": "Basato sul tempo",
|
||||
"cal_source_time_adaptive": "Basato sul tempo (adattivo)",
|
||||
"cal_source_sensor": "Basato su sensore",
|
||||
"cal_predicted": "previsto",
|
||||
"cal_confidence_high": "alta confidenza",
|
||||
"cal_confidence_medium": "media confidenza",
|
||||
"cal_confidence_low": "bassa confidenza",
|
||||
"budget_monthly": "Budget mensile",
|
||||
"budget_yearly": "Budget annuale",
|
||||
"groups": "Gruppi",
|
||||
"new_group": "Nuovo gruppo",
|
||||
"edit_group": "Modifica gruppo",
|
||||
"no_groups": "Nessun gruppo",
|
||||
"delete_group": "Elimina gruppo",
|
||||
"delete_group_confirm": "Eliminare il gruppo '{name}'?",
|
||||
"group_select_tasks": "Seleziona attività",
|
||||
"group_name_required": "Nome richiesto",
|
||||
"description_optional": "Descrizione (opzionale)",
|
||||
"selected": "Selezionato",
|
||||
"loading_chart": "Caricamento dati...",
|
||||
"hide_outliers": "Nascondi valori anomali (errori sensore)",
|
||||
"was_maintenance_needed": "Questa manutenzione era necessaria?",
|
||||
"feedback_needed": "Necessaria",
|
||||
"feedback_not_needed": "Non necessaria",
|
||||
"feedback_not_sure": "Non sicuro",
|
||||
"suggested_interval": "Intervallo suggerito",
|
||||
"apply_suggestion": "Applica",
|
||||
"reanalyze": "Rianalizza",
|
||||
"reanalyze_result": "Nuova analisi",
|
||||
"reanalyze_insufficient_data": "Dati insufficienti per una raccomandazione",
|
||||
"data_points": "punti dati",
|
||||
"dismiss_suggestion": "Ignora",
|
||||
"confidence_low": "Bassa",
|
||||
"confidence_medium": "Media",
|
||||
"confidence_high": "Alta",
|
||||
"recommended": "consigliato",
|
||||
"seasonal_awareness": "Consapevolezza stagionale",
|
||||
"edit_seasonal_overrides": "Modifica fattori stagionali",
|
||||
"seasonal_overrides_title": "Fattori stagionali (override)",
|
||||
"seasonal_overrides_hint": "Fattore per mese (0.1–5.0). Vuoto = appreso automaticamente.",
|
||||
"seasonal_override_invalid": "Valore non valido",
|
||||
"seasonal_override_range": "Il fattore deve essere tra 0.1 e 5.0",
|
||||
"clear_all": "Cancella tutto",
|
||||
"seasonal_chart_title": "Fattori stagionali",
|
||||
"seasonal_learned": "Appreso",
|
||||
"seasonal_manual": "Manuale",
|
||||
"month_jan": "Gen",
|
||||
"month_feb": "Feb",
|
||||
"month_mar": "Mar",
|
||||
"month_apr": "Apr",
|
||||
"month_may": "Mag",
|
||||
"month_jun": "Giu",
|
||||
"month_jul": "Lug",
|
||||
"month_aug": "Ago",
|
||||
"month_sep": "Set",
|
||||
"month_oct": "Ott",
|
||||
"month_nov": "Nov",
|
||||
"month_dec": "Dic",
|
||||
"sensor_prediction": "Previsione sensore",
|
||||
"degradation_trend": "Tendenza",
|
||||
"trend_rising": "In aumento",
|
||||
"trend_falling": "In calo",
|
||||
"trend_stable": "Stabile",
|
||||
"trend_insufficient_data": "Dati insufficienti",
|
||||
"days_until_threshold": "Giorni alla soglia",
|
||||
"threshold_exceeded": "Soglia superata",
|
||||
"environmental_adjustment": "Fattore ambientale",
|
||||
"sensor_prediction_urgency": "Il sensore prevede la soglia tra ~{days} giorni",
|
||||
"day_short": "giorno",
|
||||
"weibull_reliability_curve": "Curva di affidabilità",
|
||||
"weibull_failure_probability": "Probabilità di guasto",
|
||||
"weibull_r_squared": "Adattamento R²",
|
||||
"beta_early_failures": "Guasti precoci",
|
||||
"beta_random_failures": "Guasti casuali",
|
||||
"beta_wear_out": "Usura",
|
||||
"beta_highly_predictable": "Altamente prevedibile",
|
||||
"confidence_interval": "Intervallo di confidenza",
|
||||
"confidence_conservative": "Conservativo",
|
||||
"confidence_aggressive": "Ottimistico",
|
||||
"current_interval_marker": "Intervallo attuale",
|
||||
"recommended_marker": "Consigliato",
|
||||
"characteristic_life": "Vita caratteristica",
|
||||
"chart_mini_sparkline": "Sparkline di tendenza",
|
||||
"chart_history": "Cronologia costi e durata",
|
||||
"chart_seasonal": "Fattori stagionali, 12 mesi",
|
||||
"chart_weibull": "Curva di affidabilità Weibull",
|
||||
"chart_sparkline": "Grafico valore trigger sensore",
|
||||
"days_progress": "Avanzamento giorni",
|
||||
"qr_code": "Codice QR",
|
||||
"qr_generating": "Generazione codice QR…",
|
||||
"qr_error": "Impossibile generare il codice QR.",
|
||||
"qr_error_no_url": "Nessun URL HA configurato. Impostare un URL esterno o interno in Impostazioni → Sistema → Rete.",
|
||||
"save_error": "Salvataggio non riuscito. Riprovare.",
|
||||
"qr_print": "Stampa",
|
||||
"qr_download": "Scarica SVG",
|
||||
"qr_action": "Azione alla scansione",
|
||||
"qr_action_view": "Visualizza info manutenzione",
|
||||
"qr_action_complete": "Segna manutenzione come completata",
|
||||
"qr_url_mode": "Tipo di link",
|
||||
"qr_mode_companion": "Companion App",
|
||||
"qr_mode_local": "Locale (mDNS)",
|
||||
"qr_mode_server": "URL server",
|
||||
"overview": "Panoramica",
|
||||
"analysis": "Analisi",
|
||||
"recent_activities": "Attività recenti",
|
||||
"search_notes": "Cerca nelle note",
|
||||
"avg_cost": "Ø Costo",
|
||||
"no_advanced_features": "Nessuna funzione avanzata attivata",
|
||||
"no_advanced_features_hint": "Attiva “Intervalli Adattivi” o “Modelli Stagionali” nelle impostazioni dell'integrazione per vedere i dati di analisi qui.",
|
||||
"analysis_not_enough_data": "Non ci sono ancora abbastanza dati per l'analisi.",
|
||||
"analysis_not_enough_data_hint": "L'analisi Weibull richiede almeno 5 manutenzioni completate; i modelli stagionali diventano visibili dopo 6+ punti dati al mese.",
|
||||
"analysis_manual_task_hint": "Le attività manuali senza intervallo non generano dati di analisi.",
|
||||
"completions": "completamenti",
|
||||
"current": "Attuale",
|
||||
"shorter": "Più breve",
|
||||
"longer": "Più lungo",
|
||||
"normal": "Normale",
|
||||
"disabled": "Disattivato",
|
||||
"compound_logic": "Logica composta",
|
||||
"compound": "Composto (più condizioni)",
|
||||
"compound_logic_and": "E — tutte le condizioni devono attivarsi",
|
||||
"compound_logic_or": "O — basta una condizione",
|
||||
"compound_help": "Combina più condizioni dei sensori in un unico trigger.",
|
||||
"compound_no_conditions": "Ancora nessuna condizione — aggiungine almeno una.",
|
||||
"compound_add_condition": "Aggiungi condizione",
|
||||
"compound_condition": "Condizione",
|
||||
"compound_remove_condition": "Rimuovi condizione",
|
||||
"card_title": "Titolo",
|
||||
"card_show_header": "Mostra intestazione con statistiche",
|
||||
"card_show_actions": "Mostra pulsanti azione",
|
||||
"card_compact": "Modalità compatta",
|
||||
"card_max_items": "Max elementi (0 = tutti)",
|
||||
"card_filter_status": "Filtra per stato",
|
||||
"card_filter_status_help": "Vuoto = mostra tutti gli stati.",
|
||||
"card_filter_objects": "Filtra per oggetti",
|
||||
"card_filter_objects_help": "Vuoto = mostra tutti gli oggetti.",
|
||||
"card_filter_entities": "Filtra per entità (entity_ids)",
|
||||
"card_filter_entities_help": "Seleziona entità sensor / binary_sensor da questa integrazione. Vuoto = tutte.",
|
||||
"card_loading_objects": "Caricamento oggetti…",
|
||||
"card_load_error": "Impossibile caricare gli oggetti — verifica la connessione WebSocket.",
|
||||
"card_no_tasks_title": "Nessuna attività di manutenzione",
|
||||
"card_no_tasks_cta": "→ Creane una nel pannello Manutenzione",
|
||||
"no_objects": "Nessun oggetto ancora.",
|
||||
"action_error": "Azione fallita. Riprova.",
|
||||
"area_id_optional": "Area (opzionale)",
|
||||
"installation_date_optional": "Data di installazione (opzionale)",
|
||||
"warranty_expiry_optional": "Scadenza garanzia (opzionale)",
|
||||
"warranty": "Garanzia",
|
||||
"warranty_valid_until": "valida fino al {date}",
|
||||
"warranty_expires_in": "scade tra {days} giorni",
|
||||
"warranty_expired": "scaduta",
|
||||
"cal_past_windows": "Finestre passate",
|
||||
"cal_forward_windows": "Finestre future",
|
||||
"history_edit_title": "Modifica voce dello storico",
|
||||
"history_edit_timestamp": "Data e ora",
|
||||
"manufacturer": "Produttore",
|
||||
"model": "Modello",
|
||||
"area": "Area",
|
||||
"actions": "Azioni",
|
||||
"view_mode_label": "Vista",
|
||||
"view_cards": "Vista schede",
|
||||
"view_table": "Vista tabella",
|
||||
"objects_table_columns_label": "Colonne tabella oggetti",
|
||||
"objects_table_columns_hint": "Scegli quali colonne mostrare nella vista tabella degli oggetti.",
|
||||
"custom_icon_optional": "Icona (opzionale, es. mdi:wrench)",
|
||||
"task_enabled": "Attività abilitata",
|
||||
"skip_reason_prompt": "Saltare questa attività?",
|
||||
"reason_optional": "Motivo (opzionale)",
|
||||
"reset_date_prompt": "Segnare l'attività come eseguita?",
|
||||
"reset_date_optional": "Data ultima esecuzione (opzionale, predefinito: oggi)",
|
||||
"notes_label": "Note",
|
||||
"documentation_label": "Documentazione",
|
||||
"no_nfc_tag": "— Nessun tag —",
|
||||
"dashboard": "Dashboard",
|
||||
"tab_today": "Oggi",
|
||||
"palette_placeholder": "Cerca oggetti e attività…",
|
||||
"palette_no_results": "Nessun risultato",
|
||||
"palette_hint": "↑↓ naviga · Invio apri · Esc chiudi",
|
||||
"today_all_caught_up": "Tutto fatto! Niente in scadenza questa settimana.",
|
||||
"today_overdue": "Scadute",
|
||||
"today_due_today": "In scadenza oggi",
|
||||
"today_this_week": "Questa settimana",
|
||||
"settings": "Impostazioni",
|
||||
"settings_features": "Funzioni avanzate",
|
||||
"settings_features_desc": "Attiva o disattiva le funzioni avanzate. La disattivazione le nasconde dall'interfaccia ma non elimina i dati.",
|
||||
"feat_adaptive": "Pianificazione adattiva",
|
||||
"feat_adaptive_desc": "Impara intervalli ottimali dalla cronologia di manutenzione",
|
||||
"feat_predictions": "Previsioni sensore",
|
||||
"feat_predictions_desc": "Prevedi date di attivazione dalla degradazione dei sensori",
|
||||
"feat_seasonal": "Adeguamenti stagionali",
|
||||
"feat_seasonal_desc": "Adegua gli intervalli in base ai modelli stagionali",
|
||||
"feat_environmental": "Correlazione ambientale",
|
||||
"feat_environmental_desc": "Correla gli intervalli con temperatura/umidità",
|
||||
"feat_budget": "Monitoraggio budget",
|
||||
"feat_budget_desc": "Monitora le spese di manutenzione mensili e annuali",
|
||||
"feat_groups": "Gruppi di attività",
|
||||
"feat_groups_desc": "Organizza le attività in gruppi logici",
|
||||
"feat_checklists": "Checklist",
|
||||
"feat_checklists_desc": "Procedure multi-fase per il completamento delle attività",
|
||||
"settings_general": "Generale",
|
||||
"settings_default_warning": "Giorni di avviso predefiniti",
|
||||
"settings_panel_enabled": "Pannello laterale",
|
||||
"settings_panel_title": "Titolo pannello laterale",
|
||||
"settings_notifications": "Notifiche",
|
||||
"settings_notify_service": "Servizio di notifica",
|
||||
"test_notification": "Notifica di test",
|
||||
"send_test": "Invia test",
|
||||
"testing": "Invio in corso…",
|
||||
"test_notification_success": "Notifica di test inviata",
|
||||
"test_notification_failed": "Notifica di test non riuscita",
|
||||
"settings_notify_due_soon": "Notifica quando in scadenza",
|
||||
"settings_notify_overdue": "Notifica quando scaduto",
|
||||
"settings_notify_triggered": "Notifica quando attivato",
|
||||
"settings_interval_hours": "Intervallo di ripetizione (ore, 0 = una volta)",
|
||||
"settings_quiet_hours": "Ore di silenzio",
|
||||
"settings_quiet_start": "Inizio",
|
||||
"settings_quiet_end": "Fine",
|
||||
"settings_max_per_day": "Max notifiche al giorno (0 = illimitato)",
|
||||
"settings_bundling": "Raggruppare le notifiche",
|
||||
"settings_bundle_threshold": "Soglia di raggruppamento",
|
||||
"settings_reminder_leads": "Extra reminders (days before due)",
|
||||
"settings_reminder_leads_hint": "Comma-separated lead times, e.g. 14, 3, 0 — one extra reminder fires on each matching day. Empty = off.",
|
||||
"settings_actions": "Pulsanti azione mobili",
|
||||
"settings_action_complete": "Mostra pulsante 'Completato'",
|
||||
"settings_action_skip": "Mostra pulsante 'Salta'",
|
||||
"settings_action_snooze": "Mostra pulsante 'Posticipa'",
|
||||
"settings_weekly_digest": "Riepilogo settimanale",
|
||||
"settings_weekly_digest_hint": "Una notifica di riepilogo il lunedì mattina quando ci sono attività.",
|
||||
"settings_warranty_reminder": "Warranty expiry reminder",
|
||||
"settings_warranty_reminder_days": "Days before expiry",
|
||||
"settings_warranty_reminder_hint": "Notify once when an object's warranty is this many days from expiring.",
|
||||
"settings_snooze_hours": "Durata posticipo (ore)",
|
||||
"settings_budget": "Budget",
|
||||
"settings_currency": "Valuta",
|
||||
"settings_budget_monthly": "Budget mensile",
|
||||
"settings_budget_yearly": "Budget annuale",
|
||||
"settings_budget_alerts": "Avvisi budget",
|
||||
"settings_budget_threshold": "Soglia di avviso (%)",
|
||||
"settings_import_export": "Import / Export",
|
||||
"settings_export_json": "Esporta JSON",
|
||||
"settings_export_yaml": "Esporta YAML",
|
||||
"settings_export_csv": "Esporta CSV",
|
||||
"settings_import_csv": "Importa CSV",
|
||||
"settings_import_placeholder": "Incolla il contenuto JSON o CSV qui…",
|
||||
"settings_import_btn": "Importa",
|
||||
"settings_import_success": "{count} oggetti importati con successo.",
|
||||
"settings_export_success": "Export scaricato.",
|
||||
"settings_saved": "Impostazione salvata.",
|
||||
"settings_include_history": "Includi cronologia",
|
||||
"sort_alphabetical": "Alfabetico",
|
||||
"sort_due_soonest": "Scadenza più vicina",
|
||||
"sort_task_count": "Numero di attività",
|
||||
"sort_area": "Area",
|
||||
"sort_assigned_user": "Utente assegnato",
|
||||
"sort_group": "Gruppo",
|
||||
"groupby_none": "Nessun raggruppamento",
|
||||
"groupby_area": "Per area",
|
||||
"groupby_group": "Per gruppo",
|
||||
"groupby_user": "Per utente",
|
||||
"filter_label": "Filtro",
|
||||
"user_label": "Utente",
|
||||
"sort_label": "Ordinamento",
|
||||
"group_by_label": "Raggruppa per",
|
||||
"state_value_help": "Usa il valore di stato HA (di solito minuscolo, es. \"on\"/\"off\"). Il case viene normalizzato al salvataggio.",
|
||||
"target_changes_help": "Numero di transizioni corrispondenti prima che il trigger si attivi (predefinito: 1).",
|
||||
"qr_print_title": "Stampa codici QR",
|
||||
"qr_print_desc": "Genera una pagina stampabile di codici QR da ritagliare e applicare sulle apparecchiature.",
|
||||
"qr_print_load": "Carica oggetti",
|
||||
"qr_print_filter": "Filtro",
|
||||
"qr_print_objects": "Oggetti",
|
||||
"qr_print_actions": "Azioni",
|
||||
"qr_print_url_mode": "Tipo di link",
|
||||
"qr_print_estimate": "Codici QR stimati",
|
||||
"qr_print_over_limit": "limite 200, restringi il filtro",
|
||||
"qr_print_generate": "Genera codici QR",
|
||||
"qr_print_generating": "Generazione…",
|
||||
"qr_print_ready": "Codici QR pronti",
|
||||
"qr_print_print_button": "Stampa",
|
||||
"qr_print_empty": "Niente da generare",
|
||||
"qr_action_skip": "Salta",
|
||||
"vacation_title": "Modalità vacanza",
|
||||
"vacation_active": "attiva",
|
||||
"vacation_ended": "terminata",
|
||||
"vacation_desc": "Pianifica le tue vacanze: le notifiche vengono messe in pausa durante il periodo più giorni di buffer. Puoi escludere singole attività.",
|
||||
"vacation_enable": "Attiva modalità vacanza",
|
||||
"vacation_start": "Inizio",
|
||||
"vacation_end": "Fine",
|
||||
"vacation_buffer": "Buffer (giorni)",
|
||||
"vacation_exempt_title": "Notifica comunque durante le vacanze",
|
||||
"vacation_exempt_desc": "Scegli attività che devono notificare anche in vacanza (es. chimica della piscina critica).",
|
||||
"vacation_load_tasks": "Carica attività",
|
||||
"vacation_preview_btn": "Mostra anteprima",
|
||||
"vacation_preview_affected": "attività interessate",
|
||||
"vacation_event_due_soon": "sarà in scadenza",
|
||||
"vacation_event_overdue": "diventerà scaduta",
|
||||
"vacation_event_triggered_est": "trigger sensore possibile",
|
||||
"vacation_sensor_based": "(basato su sensore)",
|
||||
"vacation_action_notify": "Notifica comunque",
|
||||
"vacation_action_unsilence": "Silenzia di nuovo",
|
||||
"vacation_marked_complete": "Segnato come completato",
|
||||
"vacation_marked_skip": "Saltato",
|
||||
"vacation_end_now": "Termina vacanza ora",
|
||||
"unassigned": "Non assegnato",
|
||||
"no_area": "Nessuna area",
|
||||
"has_overdue": "Attività scadute",
|
||||
"object": "Oggetto",
|
||||
"settings_panel_access": "Accesso al pannello",
|
||||
"settings_panel_access_desc": "Gli amministratori hanno sempre accesso completo. Per delegare creazione, modifica ed eliminazione a non amministratori specifici, attiva questa opzione e selezionali qui sotto — gli altri vedono solo Completa e Salta.",
|
||||
"settings_operator_write": "Consenti agli utenti selezionati di creare, modificare ed eliminare",
|
||||
"settings_operator_write_desc": "Disattivato: solo gli amministratori possono modificare i contenuti. Attivato: anche gli utenti selezionati qui sotto hanno accesso completo.",
|
||||
"no_non_admin_users": "Nessun utente non amministratore trovato. Aggiungili in Impostazioni → Persone.",
|
||||
"owner_label": "Proprietario",
|
||||
"feat_completion_actions": "Azioni di completamento",
|
||||
"feat_completion_actions_desc": "Azione HA per attività al completamento + QR completamento rapido con valori predefiniti.",
|
||||
"on_complete_action_title": "Al completamento: attiva azione HA (opzionale)",
|
||||
"on_complete_action_desc": "Chiama un servizio HA quando l'attività viene completata — es. azzerare un contatore sul dispositivo.",
|
||||
"on_complete_action_service": "Servizio",
|
||||
"on_complete_action_target": "Entità target",
|
||||
"on_complete_action_data": "Dati (JSON, opzionale)",
|
||||
"on_complete_action_test": "Testa azione",
|
||||
"on_complete_action_test_success": "Riuscito",
|
||||
"on_complete_action_test_failed": "Fallito",
|
||||
"quick_complete_defaults_title": "Valori predefiniti completamento rapido (per scansioni QR, opzionale)",
|
||||
"quick_complete_defaults_desc": "Valori predefiniti per QR di completamento rapido. Senza, il QR apre la finestra di completamento.",
|
||||
"quick_complete_defaults_notes": "Note",
|
||||
"quick_complete_defaults_cost": "Costo",
|
||||
"quick_complete_defaults_duration": "Durata (minuti)",
|
||||
"quick_complete_defaults_feedback_none": "Nessun feedback",
|
||||
"quick_complete_defaults_feedback_needed": "Era necessario",
|
||||
"quick_complete_defaults_feedback_not_needed": "Non necessario",
|
||||
"quick_complete_success": "Completato rapidamente",
|
||||
"trigger_replaced": "Trigger sostituito",
|
||||
"add": "Aggiungi",
|
||||
"show_stats": "Mostra statistiche + grafici",
|
||||
"hide_stats": "Nascondi statistiche",
|
||||
"adaptive_no_data": "Storico dei completamenti ancora insufficiente per l'analisi adattiva. Completa questa attività ancora qualche volta per sbloccare i suggerimenti sull'intervallo e i grafici di affidabilità.",
|
||||
"suggestion_applied": "Intervallo suggerito applicato",
|
||||
"vacation_mode": "Modalità vacanza",
|
||||
"vacation_status_active": "Attiva ora",
|
||||
"vacation_status_scheduled": "Pianificata",
|
||||
"vacation_status_inactive": "Inattiva",
|
||||
"vacation_end_now_confirm": "Terminare subito la vacanza?",
|
||||
"vacation_exempt_count": "escluse",
|
||||
"vacation_advanced": "Avanzate…",
|
||||
"vacation_open_panel": "Apri nel pannello",
|
||||
"enable": "Attiva",
|
||||
"saved": "Salvato",
|
||||
"budget_monthly_set": "Imposta mensile",
|
||||
"budget_yearly_set": "Imposta annuale",
|
||||
"budget_advanced": "Valuta, avvisi…",
|
||||
"budget_open_panel": "Apri nel pannello",
|
||||
"groups_empty": "Nessun gruppo ancora.",
|
||||
"group_new_placeholder": "Aggiungi gruppo…",
|
||||
"group_delete_confirm": "Eliminare il gruppo \"{name}\"?",
|
||||
"groups_manage_tasks": "Gestisci assegnazioni attività…",
|
||||
"groups_open_panel": "Apri nel pannello",
|
||||
"on_complete_action_target_hint": "Nota: il dominio dell'entità deve corrispondere al servizio — es. 'button.press' funziona solo su button.*, 'counter.increment' solo su counter.*, 'input_button.press' solo su input_button.* ecc. In caso di mancata corrispondenza l'azione fallisce silenziosamente (HA registra 'Referenced entities ... missing or not currently available').",
|
||||
"show_all_objects": "Mostra tutti gli oggetti",
|
||||
"show_all_tasks": "Azzera filtro — mostra tutte le attività",
|
||||
"filter_to_overdue": "Filtra l'elenco solo sulle attività in ritardo",
|
||||
"filter_to_due_soon": "Filtra l'elenco solo sulle attività in scadenza",
|
||||
"filter_to_triggered": "Filtra l'elenco solo sulle attività attivate",
|
||||
"open_task": "Apri attività",
|
||||
"show_details": "Mostra storico + statistiche",
|
||||
"hide_details": "Nascondi dettagli",
|
||||
"history_empty": "Nessuno storico ancora.",
|
||||
"history_edit_button": "Modifica voce",
|
||||
"total_cost": "Costo totale",
|
||||
"times_performed": "Eseguita",
|
||||
"older_entries": "precedenti",
|
||||
"open_in_panel": "Apri nel pannello Manutenzione",
|
||||
"skip_reason": "Motivo del salto (facoltativo)",
|
||||
"reset_to_date": "Reimposta ultima esecuzione al",
|
||||
"delete_task_confirm": "Eliminare questa attività e il suo storico?",
|
||||
"delete_object_confirm": "Eliminare questo oggetto e tutte le sue attività?",
|
||||
"loading": "Caricamento…",
|
||||
"archive": "Archivia",
|
||||
"undo": "Annulla",
|
||||
"task_archived": "Attività archiviata",
|
||||
"object_archived": "Oggetto archiviato",
|
||||
"unarchive": "Annulla archiviazione",
|
||||
"archived": "Archiviato",
|
||||
"show_archived": "Mostra archiviati",
|
||||
"hide_archived": "Nascondi archiviati",
|
||||
"confirm_archive_object": "Archiviare questo oggetto e le sue attività? La cronologia viene conservata e può essere ripristinata in seguito.",
|
||||
"settings_archive": "Archiviazione e conservazione",
|
||||
"settings_archive_desc": "Metti da parte le attività una tantum completate senza eliminarle. Gli elementi archiviati sono nascosti e inattivi, ma conservano cronologia e costi.",
|
||||
"settings_archive_oneoff_days": "Archivia automaticamente le attività una tantum completate dopo (giorni, 0 = off)",
|
||||
"settings_delete_archived_oneoff_days": "Elimina automaticamente le attività una tantum archiviate dopo (giorni, 0 = mai)",
|
||||
"archive_object": "Archivia oggetto",
|
||||
"unarchive_object": "Annulla archiviazione oggetto",
|
||||
"documents": "Documenti",
|
||||
"documents_empty": "Ancora nessun documento.",
|
||||
"doc_upload": "Carica file",
|
||||
"doc_uploading": "Caricamento…",
|
||||
"doc_add_link": "Aggiungi link",
|
||||
"doc_link_url": "URL (https://…)",
|
||||
"doc_link_title": "Titolo (facoltativo)",
|
||||
"doc_open": "Apri",
|
||||
"doc_delete_confirm": "Eliminare «{name}»?",
|
||||
"doc_too_large": "File troppo grande (max 25 MB).",
|
||||
"doc_upload_failed": "Caricamento non riuscito.",
|
||||
"completion_photo_optional": "Completion photo (optional)",
|
||||
"add_photo": "Add photo",
|
||||
"uploading": "Uploading…",
|
||||
"remove": "Remove",
|
||||
"doc_deduped": "Già archiviato altrove — condiviso, nessuno spazio aggiuntivo.",
|
||||
"doc_dup_in_object": "Questo file è già allegato a questo oggetto.",
|
||||
"doc_link_invalid": "Sono consentiti solo link http/https.",
|
||||
"doc_cat_manual": "Manuale",
|
||||
"doc_cat_warranty": "Garanzia",
|
||||
"doc_cat_invoice": "Fattura",
|
||||
"doc_cat_spare_parts": "Ricambi",
|
||||
"doc_cat_photo": "Foto",
|
||||
"doc_cat_other": "Altro",
|
||||
"doc_link_badge": "Link",
|
||||
"doc_storage_title": "Archiviazione documenti",
|
||||
"doc_storage_saved": "Risparmiato con deduplicazione",
|
||||
"doc_storage_refresh": "Aggiorna",
|
||||
"doc_download": "Scarica",
|
||||
"doc_close": "Chiudi",
|
||||
"doc_camera": "Scatta foto",
|
||||
"doc_drop_hint": "Trascina qui i file",
|
||||
"doc_task_none": "Nessun documento collegato a questa attività.",
|
||||
"doc_link_existing": "Collega un documento…",
|
||||
"doc_attach": "Collega",
|
||||
"doc_unlink": "Scollega",
|
||||
"doc_page": "Pagina",
|
||||
"chart_range_7d": "7g",
|
||||
"chart_range_30d": "30g",
|
||||
"chart_range_90d": "90g",
|
||||
"chart_range_1y": "1a",
|
||||
"chart_since_service": "dall'ultima manutenzione",
|
||||
"chart_no_stats": "Nessuna statistica a lungo termine per questa entità — vengono mostrati solo i valori degli interventi",
|
||||
"auto_complete_on_recovery": "Completa automaticamente quando il sensore si ripristina",
|
||||
"auto_complete_on_recovery_help": "Registra un completamento (imposta l'ultima esecuzione) quando il trigger si risolve da solo — ad es. sale ricaricato, filtro sostituito.",
|
||||
"doc_search": "Cerca documenti…",
|
||||
"doc_search_none": "Nessun documento corrispondente",
|
||||
"link_device_optional": "Collega a dispositivo esistente (opzionale)",
|
||||
"parent_object_optional": "Oggetto principale (opzionale)",
|
||||
"parent_none": "(Nessun principale)",
|
||||
"paused": "In pausa",
|
||||
"pause_object": "Metti in pausa",
|
||||
"resume_object": "Riprendi",
|
||||
"pause_until_prompt": "Congela i calendari di questo oggetto: nulla scade e nulla notifica finché non viene ripreso. Facoltativamente imposta una data di ripresa automatica.",
|
||||
"pause_until_label": "Riprendi il (facoltativo)",
|
||||
"object_paused": "Oggetto in pausa",
|
||||
"object_resumed": "Oggetto ripreso — calendari riavviati",
|
||||
"object_paused_badge": "In pausa",
|
||||
"paused_until_label": "fino al",
|
||||
"replace_object": "Sostituisci…",
|
||||
"replace_object_prompt": "Ritira questo oggetto e crea un successore. Storico e costi restano archiviati sul vecchio; attività e documenti passano al nuovo, i contatori ripartono da zero.",
|
||||
"replace_name_label": "Nome del successore",
|
||||
"object_replaced": "Oggetto sostituito — successore creato",
|
||||
"reading_unit_label": "Unità di lettura (es. kWh, m³)",
|
||||
"reading_unit_help": "Mostrata accanto al valore registrato al completamento di questa attività.",
|
||||
"reading_value_label": "Valore letto",
|
||||
"reading_label": "Lettura",
|
||||
"settings_templates_label": "Galleria modelli",
|
||||
"settings_templates_hint": "Deseleziona i modelli che non ti serviranno mai: spariscono dai selettori \"Da modello\" (pannello e config flow). Nient'altro cambia; riattivabili in qualsiasi momento.",
|
||||
"worksheet": "Scheda di lavoro",
|
||||
"worksheet_scan_view": "Scansiona per aprire l'attività",
|
||||
"worksheet_scan_complete": "Scansiona per completare",
|
||||
"worksheet_manual_excerpt": "Estratto del manuale",
|
||||
"worksheet_pages": "pagine",
|
||||
"worksheet_printed": "Stampato",
|
||||
"worksheet_never": "Mai"
|
||||
}
|
||||
@@ -0,0 +1,696 @@
|
||||
{
|
||||
"maintenance": "メンテナンス",
|
||||
"objects": "対象",
|
||||
"tasks": "タスク",
|
||||
"overdue": "期限超過",
|
||||
"due_soon": "期限間近",
|
||||
"triggered": "トリガー発生",
|
||||
"trigger_replaced": "トリガー解除済み",
|
||||
"ok": "OK",
|
||||
"all": "すべて",
|
||||
"new_object": "+ 新規対象",
|
||||
"templates_from": "テンプレートから",
|
||||
"templates_title": "テンプレートから始める",
|
||||
"templates_task_count": "{n} 件のタスク",
|
||||
"template_created": "テンプレートから作成しました",
|
||||
"onboard_hint": "最初のオブジェクトを追加してメンテナンスの記録を始めましょう。",
|
||||
"edit": "編集",
|
||||
"duplicate": "複製",
|
||||
"task_duplicated": "タスクを複製しました",
|
||||
"object_duplicated": "オブジェクトを複製しました",
|
||||
"delete": "削除",
|
||||
"add_task": "+ タスク追加",
|
||||
"complete": "完了",
|
||||
"completed": "完了済み",
|
||||
"skip": "スキップ",
|
||||
"skipped": "スキップ済み",
|
||||
"missed": "Missed",
|
||||
"reset": "リセット",
|
||||
"snooze": "Snooze",
|
||||
"snoozed": "Snoozed",
|
||||
"cancel": "キャンセル",
|
||||
"bulk_select": "選択",
|
||||
"bulk_select_all": "すべて選択",
|
||||
"bulk_n_selected": "{n} 件選択",
|
||||
"bulk_completed": "{n} 件のタスクを完了",
|
||||
"bulk_archived": "{n} 件のタスクをアーカイブ",
|
||||
"completing": "完了処理中…",
|
||||
"interval": "間隔",
|
||||
"warning": "警告",
|
||||
"last_performed": "最終実施",
|
||||
"next_due": "次回期限",
|
||||
"days_until_due": "期限までの日数",
|
||||
"avg_duration": "平均所要時間",
|
||||
"trigger": "トリガー",
|
||||
"trigger_type": "トリガー種別",
|
||||
"threshold_above": "上限",
|
||||
"threshold_below": "下限",
|
||||
"threshold": "しきい値",
|
||||
"counter": "カウンター",
|
||||
"state_change": "状態変化",
|
||||
"runtime": "稼働時間",
|
||||
"runtime_hours": "目標稼働時間(時間)",
|
||||
"target_value": "目標値",
|
||||
"baseline": "基準値",
|
||||
"target_changes": "変化回数",
|
||||
"for_minutes": "継続(分)",
|
||||
"time_based": "時間ベース",
|
||||
"sensor_based": "センサーベース",
|
||||
"manual": "手動",
|
||||
"one_time": "1回のみ",
|
||||
"weekdays": "曜日",
|
||||
"nth_weekday": "毎月第N曜日",
|
||||
"day_of_month": "毎月の日",
|
||||
"recurrence_on_days": "繰り返す曜日",
|
||||
"recurrence_occurrence": "出現週",
|
||||
"recurrence_weekday": "曜日",
|
||||
"recurrence_day": "日(1〜31)",
|
||||
"recurrence_last_day": "Last day of the month",
|
||||
"recurrence_business_day": "Business days only (roll back from weekend)",
|
||||
"recurrence_offset": "Offset (days, ±)",
|
||||
"recurrence_offset_help": "Shift the date by ±N days, e.g. -2 = two days before.",
|
||||
"last_day_month": "Last day of month",
|
||||
"last_business_day_month": "Last business day",
|
||||
"ord_1": "第1",
|
||||
"ord_2": "第2",
|
||||
"ord_3": "第3",
|
||||
"ord_4": "第4",
|
||||
"ord_5": "第5",
|
||||
"ord_last": "最終",
|
||||
"day_word": "日",
|
||||
"interval_value": "間隔",
|
||||
"interval_unit": "単位",
|
||||
"unit_days": "日",
|
||||
"unit_weeks": "週",
|
||||
"unit_months": "か月",
|
||||
"unit_years": "年",
|
||||
"due_date": "期限日",
|
||||
"cleaning": "清掃",
|
||||
"inspection": "点検",
|
||||
"replacement": "交換",
|
||||
"calibration": "校正",
|
||||
"service": "保守",
|
||||
"reading": "読み取り",
|
||||
"custom": "カスタム",
|
||||
"history": "履歴",
|
||||
"cost": "費用",
|
||||
"report_button": "レポート",
|
||||
"report_title": "メンテナンスレポート",
|
||||
"report_generated": "作成日",
|
||||
"report_times_done": "実施回数",
|
||||
"report_total_cost": "合計費用",
|
||||
"report_every": "{n} {unit} ごと",
|
||||
"report_notes": "メモ",
|
||||
"report_col_type": "種類",
|
||||
"report_col_status": "状態",
|
||||
"report_col_schedule": "スケジュール",
|
||||
"duration": "所要時間",
|
||||
"both": "両方",
|
||||
"trigger_val": "トリガー値",
|
||||
"complete_title": "完了: ",
|
||||
"checklist": "チェックリスト",
|
||||
"checklist_steps_optional": "チェックリストの手順(任意)",
|
||||
"checklist_placeholder": "フィルター清掃\nシール交換\n圧力テスト",
|
||||
"checklist_help": "1行に1手順。最大100項目。",
|
||||
"err_too_long": "{field}: 長すぎます(最大{n}文字)",
|
||||
"err_too_short": "{field}: 短すぎます(最小{n}文字)",
|
||||
"err_value_too_high": "{field}: 大きすぎます(最大{n})",
|
||||
"err_value_too_low": "{field}: 小さすぎます(最小{n})",
|
||||
"err_required": "{field}: 必須です",
|
||||
"err_wrong_type": "{field}: 型が違います(期待値: {type})",
|
||||
"err_invalid_choice": "{field}: 許可されていない値です",
|
||||
"err_invalid_value": "{field}: 無効な値です",
|
||||
"feat_schedule_time": "時刻指定スケジュール",
|
||||
"feat_schedule_time_desc": "タスクが深夜0時ではなく指定した時刻に期限超過となります。",
|
||||
"schedule_time_optional": "期限の時刻(任意、HH:MM)",
|
||||
"schedule_time_help": "空欄=深夜0時(既定)。HAのタイムゾーン。",
|
||||
"at_time": "時刻",
|
||||
"notes_optional": "メモ(任意)",
|
||||
"cost_optional": "費用(任意)",
|
||||
"duration_minutes": "所要時間(分、任意)",
|
||||
"days": "日",
|
||||
"day": "日",
|
||||
"today": "今日",
|
||||
"d_overdue": "日超過",
|
||||
"no_tasks": "メンテナンスタスクがまだありません。対象を作成して始めましょう。",
|
||||
"no_tasks_short": "タスクなし",
|
||||
"no_history": "履歴がまだありません。",
|
||||
"show_all": "すべて表示",
|
||||
"cost_duration_chart": "費用と所要時間",
|
||||
"installed": "設置済み",
|
||||
"confirm_delete_object": "この対象とすべてのタスクを削除しますか?",
|
||||
"confirm_delete_task": "このタスクを削除しますか?",
|
||||
"min": "最小",
|
||||
"max": "最大",
|
||||
"save": "保存",
|
||||
"saving": "保存中…",
|
||||
"edit_task": "タスクを編集",
|
||||
"new_task": "新規メンテナンスタスク",
|
||||
"task_name": "タスク名",
|
||||
"maintenance_type": "メンテナンス種別",
|
||||
"priority": "優先度",
|
||||
"labels": "Labels",
|
||||
"labels_placeholder": "e.g. safety, seasonal, tenant-visible",
|
||||
"labels_help": "Comma-separated tags for filtering and reporting.",
|
||||
"priority_low": "低",
|
||||
"priority_normal": "標準",
|
||||
"priority_high": "高",
|
||||
"schedule_type": "スケジュール種別",
|
||||
"interval_days": "間隔(日)",
|
||||
"warning_days": "警告日数",
|
||||
"earliest_completion_days": "Earliest completion (days before due)",
|
||||
"earliest_completion_days_help": "Leave empty to allow completing any time. 0 = only on/after the due date.",
|
||||
"last_performed_optional": "最終実施(任意)",
|
||||
"interval_anchor": "間隔の起点",
|
||||
"anchor_completion": "完了日から",
|
||||
"anchor_planned": "予定日から(ずれなし)",
|
||||
"edit_object": "対象を編集",
|
||||
"name": "名前",
|
||||
"manufacturer_optional": "メーカー(任意)",
|
||||
"model_optional": "型番(任意)",
|
||||
"serial_number_optional": "シリアル番号(任意)",
|
||||
"serial_number_label": "S/N",
|
||||
"documentation_url_label": "取扱説明書",
|
||||
"object_notes_label": "メモ",
|
||||
"sort_due_date": "期限日",
|
||||
"sort_object": "対象名",
|
||||
"sort_type": "種別",
|
||||
"sort_task_name": "タスク名",
|
||||
"all_objects": "すべての対象",
|
||||
"tasks_lower": "タスク",
|
||||
"no_tasks_yet": "タスクがまだありません",
|
||||
"add_first_task": "最初のタスクを追加",
|
||||
"trigger_configuration": "トリガー設定",
|
||||
"entity_id": "エンティティID",
|
||||
"comma_separated": "カンマ区切り",
|
||||
"entity_logic": "エンティティ条件",
|
||||
"entity_logic_any": "いずれかのエンティティでトリガー",
|
||||
"entity_logic_all": "すべてのエンティティが条件を満たす必要あり",
|
||||
"entities": "エンティティ",
|
||||
"attribute_optional": "属性(任意、空欄=状態)",
|
||||
"use_entity_state": "エンティティの状態を使用(属性なし)",
|
||||
"trigger_above": "この値を超えたらトリガー",
|
||||
"trigger_below": "この値を下回ったらトリガー",
|
||||
"for_at_least_minutes": "最低継続時間(分)",
|
||||
"safety_interval_days": "安全間隔(日、任意)",
|
||||
"safety_interval": "安全間隔(任意)",
|
||||
"delta_mode": "差分モード",
|
||||
"from_state_optional": "変化前の状態(任意)",
|
||||
"to_state_optional": "変化後の状態(任意)",
|
||||
"documentation_url_optional": "ドキュメントURL(任意)",
|
||||
"object_notes_optional": "メモ(任意)",
|
||||
"nfc_tag_id_optional": "NFCタグID(任意)",
|
||||
"nfc_tags_empty_help": "Home AssistantにNFCタグがまだ登録されていません。",
|
||||
"nfc_tags_open_settings": "タグ設定を開く",
|
||||
"nfc_tags_refresh": "更新",
|
||||
"environmental_entity_optional": "環境センサー(任意)",
|
||||
"environmental_entity_helper": "例: sensor.outdoor_temperature — 環境条件に応じて間隔を調整します",
|
||||
"environmental_attribute_optional": "環境属性(任意)",
|
||||
"nfc_tag_id": "NFCタグID",
|
||||
"nfc_linked": "NFCタグ連携済み",
|
||||
"nfc_link_hint": "クリックしてNFCタグを連携",
|
||||
"responsible_user": "担当ユーザー",
|
||||
"shared_with": "Shared with (rotation)",
|
||||
"shared_with_help": "Pick multiple people to share this task; the responsible person rotates on each completion.",
|
||||
"rotation_strategy": "Rotation",
|
||||
"rotation_none": "No rotation",
|
||||
"rotation_round_robin": "Round-robin",
|
||||
"rotation_least_completed": "Least completed",
|
||||
"rotation_random": "Random",
|
||||
"no_user_assigned": "(担当者なし)",
|
||||
"all_users": "すべてのユーザー",
|
||||
"my_tasks": "自分のタスク",
|
||||
"tab_calendar": "カレンダー",
|
||||
"cal_no_events": "メンテナンスなし",
|
||||
"cal_window_7": "7日間",
|
||||
"cal_window_14": "14日間",
|
||||
"cal_window_30": "30日間",
|
||||
"cal_window_365": "1年間",
|
||||
"cal_every_n_days": "{n}日ごと",
|
||||
"cal_source_time": "時間ベース",
|
||||
"cal_source_time_adaptive": "時間ベース(適応)",
|
||||
"cal_source_sensor": "センサーベース",
|
||||
"cal_predicted": "予測",
|
||||
"cal_confidence_high": "信頼度:高",
|
||||
"cal_confidence_medium": "信頼度:中",
|
||||
"cal_confidence_low": "信頼度:低",
|
||||
"budget_monthly": "月間予算",
|
||||
"budget_yearly": "年間予算",
|
||||
"groups": "グループ",
|
||||
"new_group": "新規グループ",
|
||||
"edit_group": "グループを編集",
|
||||
"no_groups": "グループがまだありません",
|
||||
"delete_group": "グループを削除",
|
||||
"delete_group_confirm": "グループ「{name}」を削除しますか?",
|
||||
"group_select_tasks": "タスクを選択",
|
||||
"group_name_required": "名前は必須です",
|
||||
"description_optional": "説明(任意)",
|
||||
"selected": "選択中",
|
||||
"loading_chart": "グラフデータを読み込み中...",
|
||||
"hide_outliers": "外れ値を隠す(センサー誤差)",
|
||||
"was_maintenance_needed": "このメンテナンスは必要でしたか?",
|
||||
"feedback_needed": "必要だった",
|
||||
"feedback_not_needed": "不要だった",
|
||||
"feedback_not_sure": "わからない",
|
||||
"suggested_interval": "推奨間隔",
|
||||
"apply_suggestion": "適用",
|
||||
"reanalyze": "再分析",
|
||||
"reanalyze_result": "新しい分析結果",
|
||||
"reanalyze_insufficient_data": "推奨を出すにはデータが不足しています",
|
||||
"data_points": "データポイント",
|
||||
"dismiss_suggestion": "閉じる",
|
||||
"confidence_low": "低",
|
||||
"confidence_medium": "中",
|
||||
"confidence_high": "高",
|
||||
"recommended": "推奨",
|
||||
"seasonal_awareness": "季節対応",
|
||||
"edit_seasonal_overrides": "季節係数を編集",
|
||||
"seasonal_overrides_title": "季節係数(上書き)",
|
||||
"seasonal_overrides_hint": "月ごとの係数(0.1〜5.0)。空欄=自動学習。",
|
||||
"seasonal_override_invalid": "無効な値です",
|
||||
"seasonal_override_range": "係数は0.1〜5.0の範囲で指定してください",
|
||||
"clear_all": "すべてクリア",
|
||||
"seasonal_chart_title": "季節係数",
|
||||
"seasonal_learned": "学習値",
|
||||
"seasonal_manual": "手動",
|
||||
"month_jan": "1月",
|
||||
"month_feb": "2月",
|
||||
"month_mar": "3月",
|
||||
"month_apr": "4月",
|
||||
"month_may": "5月",
|
||||
"month_jun": "6月",
|
||||
"month_jul": "7月",
|
||||
"month_aug": "8月",
|
||||
"month_sep": "9月",
|
||||
"month_oct": "10月",
|
||||
"month_nov": "11月",
|
||||
"month_dec": "12月",
|
||||
"sensor_prediction": "センサー予測",
|
||||
"degradation_trend": "傾向",
|
||||
"trend_rising": "上昇",
|
||||
"trend_falling": "下降",
|
||||
"trend_stable": "安定",
|
||||
"trend_insufficient_data": "データ不足",
|
||||
"days_until_threshold": "しきい値までの日数",
|
||||
"threshold_exceeded": "しきい値超過",
|
||||
"environmental_adjustment": "環境係数",
|
||||
"sensor_prediction_urgency": "センサー予測:約{days}日でしきい値到達",
|
||||
"day_short": "日",
|
||||
"weibull_reliability_curve": "信頼性曲線",
|
||||
"weibull_failure_probability": "故障確率",
|
||||
"weibull_r_squared": "適合度 R²",
|
||||
"beta_early_failures": "初期故障",
|
||||
"beta_random_failures": "偶発故障",
|
||||
"beta_wear_out": "摩耗故障",
|
||||
"beta_highly_predictable": "高い予測可能性",
|
||||
"confidence_interval": "信頼区間",
|
||||
"confidence_conservative": "保守的",
|
||||
"confidence_aggressive": "楽観的",
|
||||
"current_interval_marker": "現在の間隔",
|
||||
"recommended_marker": "推奨",
|
||||
"characteristic_life": "特性寿命",
|
||||
"chart_mini_sparkline": "傾向スパークライン",
|
||||
"chart_history": "費用と所要時間の履歴",
|
||||
"chart_seasonal": "季節係数(12か月)",
|
||||
"chart_weibull": "ワイブル信頼性曲線",
|
||||
"chart_sparkline": "センサートリガー値のグラフ",
|
||||
"days_progress": "経過日数",
|
||||
"qr_code": "QRコード",
|
||||
"qr_generating": "QRコードを生成中…",
|
||||
"qr_error": "QRコードの生成に失敗しました。",
|
||||
"qr_error_no_url": "HAのURLが設定されていません。設定 → システム → ネットワークで外部または内部URLを設定してください。",
|
||||
"save_error": "保存に失敗しました。もう一度お試しください。",
|
||||
"qr_print": "印刷",
|
||||
"qr_download": "SVGをダウンロード",
|
||||
"qr_action": "スキャン時の動作",
|
||||
"qr_action_view": "メンテナンス情報を表示",
|
||||
"qr_action_complete": "メンテナンスを完了にする",
|
||||
"qr_url_mode": "リンク種別",
|
||||
"qr_mode_companion": "コンパニオンアプリ",
|
||||
"qr_mode_local": "ローカル(mDNS)",
|
||||
"qr_mode_server": "サーバーURL",
|
||||
"overview": "概要",
|
||||
"analysis": "分析",
|
||||
"recent_activities": "最近のアクティビティ",
|
||||
"search_notes": "メモを検索",
|
||||
"avg_cost": "平均費用",
|
||||
"no_advanced_features": "高度な機能が有効になっていません",
|
||||
"no_advanced_features_hint": "統合設定で「適応間隔」または「季節パターン」を有効にすると、ここに分析データが表示されます。",
|
||||
"analysis_not_enough_data": "分析に必要なデータがまだ不足しています。",
|
||||
"analysis_not_enough_data_hint": "ワイブル分析には完了済みメンテナンスが最低5件必要です。季節パターンは月あたり6件以上のデータポイントで表示されるようになります。",
|
||||
"analysis_manual_task_hint": "間隔のない手動タスクは分析データを生成しません。",
|
||||
"completions": "完了回数",
|
||||
"current": "現在",
|
||||
"shorter": "短く",
|
||||
"longer": "長く",
|
||||
"normal": "通常",
|
||||
"disabled": "無効",
|
||||
"compound_logic": "複合条件",
|
||||
"compound": "複合(複数条件)",
|
||||
"compound_logic_and": "AND — すべての条件が必要",
|
||||
"compound_logic_or": "OR — いずれかの条件で発火",
|
||||
"compound_help": "複数のセンサー条件を1つのトリガーにまとめます。",
|
||||
"compound_no_conditions": "条件がありません — 少なくとも1つ追加してください。",
|
||||
"compound_add_condition": "条件を追加",
|
||||
"compound_condition": "条件",
|
||||
"compound_remove_condition": "条件を削除",
|
||||
"card_title": "タイトル",
|
||||
"card_show_header": "統計付きヘッダーを表示",
|
||||
"card_show_actions": "操作ボタンを表示",
|
||||
"card_compact": "コンパクトモード",
|
||||
"card_max_items": "最大表示件数(0=すべて)",
|
||||
"card_filter_status": "状態で絞り込み",
|
||||
"card_filter_status_help": "空欄=すべての状態を表示。",
|
||||
"card_filter_objects": "対象で絞り込み",
|
||||
"card_filter_objects_help": "空欄=すべての対象を表示。",
|
||||
"card_filter_entities": "エンティティで絞り込み(entity_ids)",
|
||||
"card_filter_entities_help": "この統合のsensor/binary_sensorエンティティを選択。空欄=すべて。",
|
||||
"card_loading_objects": "対象を読み込み中…",
|
||||
"card_load_error": "対象を読み込めませんでした — WebSocket接続を確認してください。",
|
||||
"card_no_tasks_title": "メンテナンスタスクがまだありません",
|
||||
"card_no_tasks_cta": "→ メンテナンスパネルで作成しましょう",
|
||||
"no_objects": "対象がまだありません。",
|
||||
"action_error": "操作に失敗しました。もう一度お試しください。",
|
||||
"area_id_optional": "エリア(任意)",
|
||||
"installation_date_optional": "設置日(任意)",
|
||||
"warranty_expiry_optional": "保証期限(任意)",
|
||||
"warranty": "保証",
|
||||
"warranty_valid_until": "{date}まで有効",
|
||||
"warranty_expires_in": "あと{days}日で期限切れ",
|
||||
"warranty_expired": "期限切れ",
|
||||
"cal_past_windows": "過去の期間",
|
||||
"cal_forward_windows": "今後の期間",
|
||||
"history_edit_title": "履歴項目を編集",
|
||||
"history_edit_timestamp": "日時",
|
||||
"manufacturer": "メーカー",
|
||||
"model": "型番",
|
||||
"area": "エリア",
|
||||
"actions": "操作",
|
||||
"view_mode_label": "表示",
|
||||
"view_cards": "カード表示",
|
||||
"view_table": "テーブル表示",
|
||||
"objects_table_columns_label": "対象テーブルの列",
|
||||
"objects_table_columns_hint": "対象のテーブル表示に表示する列を選択します。",
|
||||
"custom_icon_optional": "アイコン(任意、例: mdi:wrench)",
|
||||
"task_enabled": "タスク有効",
|
||||
"skip_reason_prompt": "このタスクをスキップしますか?",
|
||||
"reason_optional": "理由(任意)",
|
||||
"reset_date_prompt": "タスクを実施済みにしますか?",
|
||||
"reset_date_optional": "最終実施日(任意、既定は今日)",
|
||||
"notes_label": "メモ",
|
||||
"documentation_label": "ドキュメント",
|
||||
"no_nfc_tag": "— タグなし —",
|
||||
"dashboard": "ダッシュボード",
|
||||
"tab_today": "今日",
|
||||
"palette_placeholder": "オブジェクトとタスクを検索…",
|
||||
"palette_no_results": "一致なし",
|
||||
"palette_hint": "↑↓ 移動 · Enter 開く · Esc 閉じる",
|
||||
"today_all_caught_up": "すべて完了!今週の予定はありません。",
|
||||
"today_overdue": "期限切れ",
|
||||
"today_due_today": "今日期限",
|
||||
"today_this_week": "今週",
|
||||
"settings": "設定",
|
||||
"settings_features": "高度な機能",
|
||||
"settings_features_desc": "高度な機能の有効/無効を切り替えます。無効にするとUIから非表示になりますが、データは削除されません。",
|
||||
"feat_adaptive": "適応スケジューリング",
|
||||
"feat_adaptive_desc": "メンテナンス履歴から最適な間隔を学習します",
|
||||
"feat_predictions": "センサー予測",
|
||||
"feat_predictions_desc": "センサーの劣化からトリガー日を予測します",
|
||||
"feat_seasonal": "季節調整",
|
||||
"feat_seasonal_desc": "季節パターンに基づいて間隔を調整します",
|
||||
"feat_environmental": "環境相関",
|
||||
"feat_environmental_desc": "温度・湿度と間隔を相関させます",
|
||||
"feat_budget": "予算管理",
|
||||
"feat_budget_desc": "月間・年間のメンテナンス費用を管理します",
|
||||
"feat_groups": "タスクグループ",
|
||||
"feat_groups_desc": "タスクを論理的なグループに整理します",
|
||||
"feat_checklists": "チェックリスト",
|
||||
"feat_checklists_desc": "タスク完了のための複数手順の手続き",
|
||||
"settings_general": "一般",
|
||||
"settings_default_warning": "既定の警告日数",
|
||||
"settings_panel_enabled": "サイドバーパネル",
|
||||
"settings_panel_title": "サイドバーパネルのタイトル",
|
||||
"settings_notifications": "通知",
|
||||
"settings_notify_service": "通知サービス",
|
||||
"test_notification": "テスト通知",
|
||||
"send_test": "テスト送信",
|
||||
"testing": "送信中…",
|
||||
"test_notification_success": "テスト通知を送信しました",
|
||||
"test_notification_failed": "テスト通知に失敗しました",
|
||||
"settings_notify_due_soon": "期限間近で通知",
|
||||
"settings_notify_overdue": "期限超過で通知",
|
||||
"settings_notify_triggered": "トリガー発生で通知",
|
||||
"settings_interval_hours": "繰り返し間隔(時間、0=1回のみ)",
|
||||
"settings_quiet_hours": "通知停止時間",
|
||||
"settings_quiet_start": "開始",
|
||||
"settings_quiet_end": "終了",
|
||||
"settings_max_per_day": "1日あたりの最大通知数(0=無制限)",
|
||||
"settings_bundling": "通知をまとめる",
|
||||
"settings_bundle_threshold": "まとめるしきい値",
|
||||
"settings_reminder_leads": "Extra reminders (days before due)",
|
||||
"settings_reminder_leads_hint": "Comma-separated lead times, e.g. 14, 3, 0 — one extra reminder fires on each matching day. Empty = off.",
|
||||
"settings_actions": "モバイル操作ボタン",
|
||||
"settings_action_complete": "「完了」ボタンを表示",
|
||||
"settings_action_skip": "「スキップ」ボタンを表示",
|
||||
"settings_action_snooze": "「スヌーズ」ボタンを表示",
|
||||
"settings_weekly_digest": "週間まとめ",
|
||||
"settings_weekly_digest_hint": "タスクがある月曜の朝に1件のまとめ通知。",
|
||||
"settings_warranty_reminder": "Warranty expiry reminder",
|
||||
"settings_warranty_reminder_days": "Days before expiry",
|
||||
"settings_warranty_reminder_hint": "Notify once when an object's warranty is this many days from expiring.",
|
||||
"settings_snooze_hours": "スヌーズ時間(時間)",
|
||||
"settings_budget": "予算",
|
||||
"settings_currency": "通貨",
|
||||
"settings_budget_monthly": "月間予算",
|
||||
"settings_budget_yearly": "年間予算",
|
||||
"settings_budget_alerts": "予算アラート",
|
||||
"settings_budget_threshold": "アラートしきい値(%)",
|
||||
"settings_import_export": "インポート/エクスポート",
|
||||
"settings_export_json": "JSONをエクスポート",
|
||||
"settings_export_yaml": "YAMLをエクスポート",
|
||||
"settings_export_csv": "CSVをエクスポート",
|
||||
"settings_import_csv": "CSVをインポート",
|
||||
"settings_import_placeholder": "JSONまたはCSVの内容をここに貼り付け…",
|
||||
"settings_import_btn": "インポート",
|
||||
"settings_import_success": "{count}件の対象を正常にインポートしました。",
|
||||
"settings_export_success": "エクスポートをダウンロードしました。",
|
||||
"settings_saved": "設定を保存しました。",
|
||||
"settings_include_history": "履歴を含める",
|
||||
"sort_alphabetical": "アルファベット順",
|
||||
"sort_due_soonest": "期限が近い順",
|
||||
"sort_task_count": "タスク数",
|
||||
"sort_area": "エリア",
|
||||
"sort_assigned_user": "担当ユーザー",
|
||||
"sort_group": "グループ",
|
||||
"groupby_none": "グループ化なし",
|
||||
"groupby_area": "エリア別",
|
||||
"groupby_group": "グループ別",
|
||||
"groupby_user": "ユーザー別",
|
||||
"filter_label": "絞り込み",
|
||||
"user_label": "ユーザー",
|
||||
"sort_label": "並べ替え",
|
||||
"group_by_label": "グループ化",
|
||||
"state_value_help": "HAの状態値を使用してください(通常は小文字、例: \"on\"/\"off\")。保存時に大文字小文字は正規化されます。",
|
||||
"target_changes_help": "トリガーが発生するまでに一致する遷移の回数(既定: 1)。",
|
||||
"qr_print_title": "QRコードを印刷",
|
||||
"qr_print_desc": "切り取って機器に貼れるQRコードの印刷ページを生成します。",
|
||||
"qr_print_load": "対象を読み込む",
|
||||
"qr_print_filter": "絞り込み",
|
||||
"qr_print_objects": "対象",
|
||||
"qr_print_actions": "操作",
|
||||
"qr_print_url_mode": "リンク種別",
|
||||
"qr_print_estimate": "QRコードの推定数",
|
||||
"qr_print_over_limit": "上限は200件です。絞り込んでください",
|
||||
"qr_print_generate": "QRコードを生成",
|
||||
"qr_print_generating": "生成中…",
|
||||
"qr_print_ready": "QRコードの準備完了",
|
||||
"qr_print_print_button": "印刷",
|
||||
"qr_print_empty": "生成する対象がありません",
|
||||
"qr_action_skip": "スキップ",
|
||||
"vacation_title": "休暇モード",
|
||||
"vacation_active": "有効",
|
||||
"vacation_ended": "終了済み",
|
||||
"vacation_desc": "休暇を計画します。期間中とその前後のバッファ日数の間、通知が一時停止されます。特定のタスクは通知対象に戻せます。",
|
||||
"vacation_enable": "休暇モードを有効にする",
|
||||
"vacation_start": "開始",
|
||||
"vacation_end": "終了",
|
||||
"vacation_buffer": "バッファ(日)",
|
||||
"vacation_exempt_title": "休暇中でも通知する",
|
||||
"vacation_exempt_desc": "休暇中でも通知すべきタスクを選択します(例: 重要なプールの水質管理)。",
|
||||
"vacation_load_tasks": "タスクを読み込む",
|
||||
"vacation_preview_btn": "プレビューを表示",
|
||||
"vacation_preview_affected": "件のタスクが影響を受けます",
|
||||
"vacation_event_due_soon": "期限間近になります",
|
||||
"vacation_event_overdue": "期限超過になります",
|
||||
"vacation_event_triggered_est": "センサートリガーの可能性あり",
|
||||
"vacation_sensor_based": "(センサーベース)",
|
||||
"vacation_action_notify": "通知する",
|
||||
"vacation_action_unsilence": "再び停止する",
|
||||
"vacation_marked_complete": "完了にしました",
|
||||
"vacation_marked_skip": "スキップしました",
|
||||
"vacation_end_now": "今すぐ休暇を終了",
|
||||
"add": "追加",
|
||||
"show_stats": "統計とグラフを表示",
|
||||
"hide_stats": "統計を非表示",
|
||||
"adaptive_no_data": "適応分析に必要な完了履歴がまだ不足しています。このタスクをあと数回完了すると、間隔の推奨と信頼性グラフが利用できるようになります。",
|
||||
"suggestion_applied": "推奨間隔を適用しました",
|
||||
"vacation_mode": "休暇モード",
|
||||
"vacation_status_active": "現在有効",
|
||||
"vacation_status_scheduled": "予定済み",
|
||||
"vacation_status_inactive": "無効",
|
||||
"vacation_end_now_confirm": "今すぐ休暇を終了しますか?",
|
||||
"vacation_exempt_count": "件 除外",
|
||||
"vacation_advanced": "詳細…",
|
||||
"vacation_open_panel": "パネルで開く",
|
||||
"enable": "有効にする",
|
||||
"saved": "保存しました",
|
||||
"budget_monthly_set": "月間を設定",
|
||||
"budget_yearly_set": "年間を設定",
|
||||
"budget_advanced": "通貨、アラート…",
|
||||
"budget_open_panel": "パネルで開く",
|
||||
"groups_empty": "グループがまだありません。",
|
||||
"group_new_placeholder": "グループを追加…",
|
||||
"group_delete_confirm": "グループ「{name}」を削除しますか?",
|
||||
"groups_manage_tasks": "タスクの割り当てを管理…",
|
||||
"groups_open_panel": "パネルで開く",
|
||||
"unassigned": "未割り当て",
|
||||
"no_area": "エリアなし",
|
||||
"has_overdue": "期限超過のタスクあり",
|
||||
"object": "対象",
|
||||
"settings_panel_access": "パネルアクセス",
|
||||
"settings_panel_access_desc": "管理者は常にフルアクセスできます。作成・編集・削除を特定の非管理者に委任するには、これをオンにして下記で選択してください。それ以外のユーザーには完了とスキップのみが表示されます。",
|
||||
"settings_operator_write": "選択したユーザーに作成・編集・削除を許可",
|
||||
"settings_operator_write_desc": "オフ:管理者のみがコンテンツを変更できます。オン:下記で選択したユーザーもフルアクセスできます。",
|
||||
"no_non_admin_users": "非管理者ユーザーが見つかりません。設定 → ユーザーで追加してください。",
|
||||
"owner_label": "オーナー",
|
||||
"feat_completion_actions": "完了時アクション",
|
||||
"feat_completion_actions_desc": "タスクごとの完了時HAアクション+事前設定値付きのクイック完了QR。",
|
||||
"on_complete_action_title": "完了時: HAアクションを実行(任意)",
|
||||
"on_complete_action_desc": "タスク完了時にHAサービスを呼び出します — 例: デバイスのカウンターをリセット。",
|
||||
"on_complete_action_service": "サービス",
|
||||
"on_complete_action_target": "対象エンティティ",
|
||||
"on_complete_action_target_hint": "注意: エンティティのドメインはサービスと一致する必要があります — 例: 'button.press'はbutton.*のみ、'counter.increment'はcounter.*のみ、'input_button.press'はinput_button.*のみで動作します。不一致の場合、アクションは何も表示せずに失敗します(HAのログに「Referenced entities ... missing or not currently available」と記録されます)。",
|
||||
"on_complete_action_data": "データ(JSON、任意)",
|
||||
"on_complete_action_test": "設定を検証",
|
||||
"on_complete_action_test_success": "✓ 設定は有効です(アクションはタスク完了時のみ実行されます)",
|
||||
"on_complete_action_test_failed": "失敗しました",
|
||||
"quick_complete_defaults_title": "クイック完了の既定値(QRスキャン用、任意)",
|
||||
"quick_complete_defaults_desc": "クイック完了QRスキャンの事前設定値。設定しない場合、QRは完了ダイアログを開きます。",
|
||||
"quick_complete_defaults_notes": "メモ",
|
||||
"quick_complete_defaults_cost": "費用",
|
||||
"quick_complete_defaults_duration": "所要時間(分)",
|
||||
"quick_complete_defaults_feedback_none": "フィードバックなし",
|
||||
"quick_complete_defaults_feedback_needed": "必要だった",
|
||||
"quick_complete_defaults_feedback_not_needed": "不要だった",
|
||||
"quick_complete_success": "クイック完了にしました",
|
||||
"show_all_objects": "すべての対象を表示",
|
||||
"show_all_tasks": "絞り込みを解除 — すべてのタスクを表示",
|
||||
"filter_to_overdue": "タスク一覧を期限超過のみに絞り込む",
|
||||
"filter_to_due_soon": "タスク一覧を期限間近のみに絞り込む",
|
||||
"filter_to_triggered": "タスク一覧をトリガー発生のみに絞り込む",
|
||||
"open_task": "タスクを開く",
|
||||
"show_details": "履歴と統計を表示",
|
||||
"hide_details": "詳細を非表示",
|
||||
"history_empty": "履歴がまだありません。",
|
||||
"history_edit_button": "項目を編集",
|
||||
"total_cost": "費用合計",
|
||||
"times_performed": "実施回数",
|
||||
"older_entries": "以前",
|
||||
"open_in_panel": "メンテナンスパネルで開く",
|
||||
"skip_reason": "スキップ理由(任意)",
|
||||
"reset_to_date": "最終実施日を次の日付にリセット",
|
||||
"delete_task_confirm": "このタスクと履歴を削除しますか?",
|
||||
"delete_object_confirm": "この対象とすべてのタスクを削除しますか?",
|
||||
"loading": "読み込み中…",
|
||||
"archive": "アーカイブ",
|
||||
"undo": "元に戻す",
|
||||
"task_archived": "タスクをアーカイブしました",
|
||||
"object_archived": "オブジェクトをアーカイブしました",
|
||||
"unarchive": "アーカイブ解除",
|
||||
"archived": "アーカイブ済み",
|
||||
"show_archived": "アーカイブを表示",
|
||||
"hide_archived": "アーカイブを非表示",
|
||||
"confirm_archive_object": "このオブジェクトとそのタスクをアーカイブしますか?履歴は保持され、後で元に戻せます。",
|
||||
"settings_archive": "アーカイブと保持",
|
||||
"settings_archive_desc": "完了した単発タスクを削除せずに片付けます。アーカイブされた項目は非表示で動作しませんが、履歴とコストは保持されます。",
|
||||
"settings_archive_oneoff_days": "完了した単発タスクを自動アーカイブする日数(0 = 無効)",
|
||||
"settings_delete_archived_oneoff_days": "アーカイブ済み単発タスクを自動削除する日数(0 = しない)",
|
||||
"archive_object": "オブジェクトをアーカイブ",
|
||||
"unarchive_object": "オブジェクトのアーカイブを解除",
|
||||
"documents": "ドキュメント",
|
||||
"documents_empty": "まだドキュメントがありません。",
|
||||
"doc_upload": "ファイルをアップロード",
|
||||
"doc_uploading": "アップロード中…",
|
||||
"doc_add_link": "リンクを追加",
|
||||
"doc_link_url": "URL (https://…)",
|
||||
"doc_link_title": "タイトル(任意)",
|
||||
"doc_open": "開く",
|
||||
"doc_delete_confirm": "「{name}」を削除しますか?",
|
||||
"doc_too_large": "ファイルが大きすぎます(最大 25 MB)。",
|
||||
"doc_upload_failed": "アップロードに失敗しました。",
|
||||
"completion_photo_optional": "Completion photo (optional)",
|
||||
"add_photo": "Add photo",
|
||||
"uploading": "Uploading…",
|
||||
"remove": "Remove",
|
||||
"doc_deduped": "既に別の場所に保存済み — 共有され、追加容量はかかりません。",
|
||||
"doc_dup_in_object": "このファイルは既にこのオブジェクトに添付されています。",
|
||||
"doc_link_invalid": "http/https のリンクのみ許可されています。",
|
||||
"doc_cat_manual": "取扱説明書",
|
||||
"doc_cat_warranty": "保証書",
|
||||
"doc_cat_invoice": "請求書",
|
||||
"doc_cat_spare_parts": "交換部品",
|
||||
"doc_cat_photo": "写真",
|
||||
"doc_cat_other": "その他",
|
||||
"doc_link_badge": "リンク",
|
||||
"doc_storage_title": "ドキュメントストレージ",
|
||||
"doc_storage_saved": "重複排除による節約",
|
||||
"doc_storage_refresh": "更新",
|
||||
"doc_download": "ダウンロード",
|
||||
"doc_close": "閉じる",
|
||||
"doc_camera": "写真を撮る",
|
||||
"doc_drop_hint": "ここにファイルをドロップ",
|
||||
"doc_task_none": "このタスクに紐付いたドキュメントはありません。",
|
||||
"doc_link_existing": "ドキュメントを紐付け…",
|
||||
"doc_attach": "紐付け",
|
||||
"doc_unlink": "紐付け解除",
|
||||
"doc_page": "ページ",
|
||||
"chart_range_7d": "7日",
|
||||
"chart_range_30d": "30日",
|
||||
"chart_range_90d": "90日",
|
||||
"chart_range_1y": "1年",
|
||||
"chart_since_service": "前回のメンテナンス以降",
|
||||
"chart_no_stats": "このエンティティには長期統計がありません — メンテナンス記録の値のみ表示しています",
|
||||
"auto_complete_on_recovery": "センサー回復時に自動完了",
|
||||
"auto_complete_on_recovery_help": "トリガーが自然に解消されたとき(塩の補充、フィルター交換など)に完了を記録し、最終実施日を更新します。",
|
||||
"doc_search": "ドキュメントを検索…",
|
||||
"doc_search_none": "一致するドキュメントはありません",
|
||||
"link_device_optional": "既存のデバイスにリンク(任意)",
|
||||
"parent_object_optional": "親オブジェクト(任意)",
|
||||
"parent_none": "(親なし)",
|
||||
"paused": "一時停止中",
|
||||
"pause_object": "一時停止",
|
||||
"resume_object": "再開",
|
||||
"pause_until_prompt": "このオブジェクトのスケジュールを凍結します。再開するまで期限も通知も発生しません。必要に応じて自動再開日を設定できます。",
|
||||
"pause_until_label": "再開日(任意)",
|
||||
"object_paused": "オブジェクトを一時停止しました",
|
||||
"object_resumed": "オブジェクトを再開しました — スケジュールを再スタート",
|
||||
"object_paused_badge": "一時停止中",
|
||||
"paused_until_label": "まで",
|
||||
"replace_object": "置き換え…",
|
||||
"replace_object_prompt": "このオブジェクトを退役させ、後継を作成します。履歴とコストは旧オブジェクトにアーカイブされたまま残り、タスクと文書は新しいオブジェクトへ引き継がれ、カウンターはリセットされます。",
|
||||
"replace_name_label": "後継の名前",
|
||||
"object_replaced": "オブジェクトを置き換えました — 後継を作成",
|
||||
"reading_unit_label": "検針単位(例: kWh、m³)",
|
||||
"reading_unit_help": "このタスクの完了時に記録値の横に表示されます。",
|
||||
"reading_value_label": "検針値",
|
||||
"reading_label": "検針",
|
||||
"settings_templates_label": "テンプレートギャラリー",
|
||||
"settings_templates_hint": "不要なテンプレートのチェックを外すと、「テンプレートから」の選択肢(パネルと設定フロー)から消えます。それ以外は何も変わらず、いつでも再有効化できます。",
|
||||
"worksheet": "作業シート",
|
||||
"worksheet_scan_view": "スキャンしてタスクを開く",
|
||||
"worksheet_scan_complete": "スキャンして完了",
|
||||
"worksheet_manual_excerpt": "マニュアル抜粋",
|
||||
"worksheet_pages": "ページ",
|
||||
"worksheet_printed": "印刷日",
|
||||
"worksheet_never": "未実施"
|
||||
}
|
||||
@@ -0,0 +1,696 @@
|
||||
{
|
||||
"maintenance": "Vedlikehold",
|
||||
"objects": "Objekter",
|
||||
"tasks": "Oppgaver",
|
||||
"overdue": "Forfalt",
|
||||
"due_soon": "Forfaller snart",
|
||||
"triggered": "Utløst",
|
||||
"trigger_replaced": "Utløser erstattet",
|
||||
"ok": "OK",
|
||||
"all": "Alle",
|
||||
"new_object": "+ Nytt objekt",
|
||||
"templates_from": "Fra mal",
|
||||
"templates_title": "Start fra en mal",
|
||||
"templates_task_count": "{n} oppgaver",
|
||||
"template_created": "Opprettet fra mal",
|
||||
"onboard_hint": "Legg til ditt første objekt for å spore vedlikehold.",
|
||||
"edit": "Rediger",
|
||||
"duplicate": "Dupliser",
|
||||
"task_duplicated": "Oppgave duplisert",
|
||||
"object_duplicated": "Objekt duplisert",
|
||||
"delete": "Slett",
|
||||
"add_task": "+ Legg til oppgave",
|
||||
"complete": "Fullfør",
|
||||
"completed": "Fullført",
|
||||
"skip": "Hopp over",
|
||||
"skipped": "Hoppet over",
|
||||
"missed": "Missed",
|
||||
"reset": "Tilbakestill",
|
||||
"snooze": "Snooze",
|
||||
"snoozed": "Snoozed",
|
||||
"cancel": "Avbryt",
|
||||
"bulk_select": "Velg",
|
||||
"bulk_select_all": "Velg alle",
|
||||
"bulk_n_selected": "{n} valgt",
|
||||
"bulk_completed": "{n} oppgaver fullført",
|
||||
"bulk_archived": "{n} oppgaver arkivert",
|
||||
"completing": "Fullfører…",
|
||||
"interval": "Intervall",
|
||||
"warning": "Advarsel",
|
||||
"last_performed": "Sist utført",
|
||||
"next_due": "Neste forfall",
|
||||
"days_until_due": "Dager til forfall",
|
||||
"avg_duration": "Gj.snittlig varighet",
|
||||
"trigger": "Utløser",
|
||||
"trigger_type": "Utløsertype",
|
||||
"threshold_above": "Øvre grense",
|
||||
"threshold_below": "Nedre grense",
|
||||
"threshold": "Terskel",
|
||||
"counter": "Teller",
|
||||
"state_change": "Tilstandsendring",
|
||||
"runtime": "Driftstid",
|
||||
"runtime_hours": "Måldriftstid (timer)",
|
||||
"target_value": "Målverdi",
|
||||
"baseline": "Utgangspunkt",
|
||||
"target_changes": "Antall endringer",
|
||||
"for_minutes": "I (minutter)",
|
||||
"time_based": "Tidsbasert",
|
||||
"sensor_based": "Sensorbasert",
|
||||
"manual": "Manuell",
|
||||
"one_time": "Engangs",
|
||||
"weekdays": "Ukedager",
|
||||
"nth_weekday": "N-te ukedag i måneden",
|
||||
"day_of_month": "Dag i måneden",
|
||||
"recurrence_on_days": "Gjenta på",
|
||||
"recurrence_occurrence": "Forekomst",
|
||||
"recurrence_weekday": "Ukedag",
|
||||
"recurrence_day": "Dag i måneden (1–31)",
|
||||
"recurrence_last_day": "Last day of the month",
|
||||
"recurrence_business_day": "Business days only (roll back from weekend)",
|
||||
"recurrence_offset": "Offset (days, ±)",
|
||||
"recurrence_offset_help": "Shift the date by ±N days, e.g. -2 = two days before.",
|
||||
"last_day_month": "Last day of month",
|
||||
"last_business_day_month": "Last business day",
|
||||
"ord_1": "1.",
|
||||
"ord_2": "2.",
|
||||
"ord_3": "3.",
|
||||
"ord_4": "4.",
|
||||
"ord_5": "5.",
|
||||
"ord_last": "Siste",
|
||||
"day_word": "Dag",
|
||||
"interval_value": "Intervall",
|
||||
"interval_unit": "Enhet",
|
||||
"unit_days": "Dager",
|
||||
"unit_weeks": "Uker",
|
||||
"unit_months": "Måneder",
|
||||
"unit_years": "År",
|
||||
"due_date": "Forfallsdato",
|
||||
"cleaning": "Rengjøring",
|
||||
"inspection": "Inspeksjon",
|
||||
"replacement": "Utskifting",
|
||||
"calibration": "Kalibrering",
|
||||
"service": "Service",
|
||||
"reading": "Avlesning",
|
||||
"custom": "Egendefinert",
|
||||
"history": "Historikk",
|
||||
"cost": "Kostnad",
|
||||
"report_button": "Rapport",
|
||||
"report_title": "Vedlikeholdsrapport",
|
||||
"report_generated": "Generert",
|
||||
"report_times_done": "Utført",
|
||||
"report_total_cost": "Totalkostnad",
|
||||
"report_every": "hver {n}. {unit}",
|
||||
"report_notes": "Notater",
|
||||
"report_col_type": "Type",
|
||||
"report_col_status": "Status",
|
||||
"report_col_schedule": "Tidsplan",
|
||||
"duration": "Varighet",
|
||||
"both": "Begge",
|
||||
"trigger_val": "Utløserverdi",
|
||||
"complete_title": "Fullfør: ",
|
||||
"checklist": "Sjekkliste",
|
||||
"checklist_steps_optional": "Sjekklistetrinn (valgfritt)",
|
||||
"checklist_placeholder": "Rengjør filter\nBytt pakning\nTest trykk",
|
||||
"checklist_help": "Ett trinn per linje. Maks 100 elementer.",
|
||||
"err_too_long": "{field}: for lang (maks {n} tegn)",
|
||||
"err_too_short": "{field}: for kort (min {n} tegn)",
|
||||
"err_value_too_high": "{field}: for stor (maks {n})",
|
||||
"err_value_too_low": "{field}: for liten (min {n})",
|
||||
"err_required": "{field}: påkrevd",
|
||||
"err_wrong_type": "{field}: feil type (forventet: {type})",
|
||||
"err_invalid_choice": "{field}: ikke en tillatt verdi",
|
||||
"err_invalid_value": "{field}: ugyldig verdi",
|
||||
"feat_schedule_time": "Planlegging med klokkeslett",
|
||||
"feat_schedule_time_desc": "Oppgaver forfaller på et bestemt klokkeslett i stedet for ved midnatt.",
|
||||
"schedule_time_optional": "Forfaller kl. (valgfritt, TT:MM)",
|
||||
"schedule_time_help": "Tom = midnatt (standard). HA-tidssone.",
|
||||
"at_time": "kl.",
|
||||
"notes_optional": "Notater (valgfritt)",
|
||||
"cost_optional": "Kostnad (valgfritt)",
|
||||
"duration_minutes": "Varighet i minutter (valgfritt)",
|
||||
"days": "dager",
|
||||
"day": "dag",
|
||||
"today": "I dag",
|
||||
"d_overdue": "d forfalt",
|
||||
"no_tasks": "Ingen vedlikeholdsoppgaver ennå. Opprett et objekt for å komme i gang.",
|
||||
"no_tasks_short": "Ingen oppgaver",
|
||||
"no_history": "Ingen historikkoppføringer ennå.",
|
||||
"show_all": "Vis alle",
|
||||
"cost_duration_chart": "Kostnad og varighet",
|
||||
"installed": "Installert",
|
||||
"confirm_delete_object": "Slette dette objektet og alle dets oppgaver?",
|
||||
"confirm_delete_task": "Slette denne oppgaven?",
|
||||
"min": "Min",
|
||||
"max": "Maks",
|
||||
"save": "Lagre",
|
||||
"saving": "Lagrer…",
|
||||
"edit_task": "Rediger oppgave",
|
||||
"new_task": "Ny vedlikeholdsoppgave",
|
||||
"task_name": "Oppgavenavn",
|
||||
"maintenance_type": "Vedlikeholdstype",
|
||||
"priority": "Prioritet",
|
||||
"labels": "Labels",
|
||||
"labels_placeholder": "e.g. safety, seasonal, tenant-visible",
|
||||
"labels_help": "Comma-separated tags for filtering and reporting.",
|
||||
"priority_low": "Lav",
|
||||
"priority_normal": "Normal",
|
||||
"priority_high": "Høy",
|
||||
"schedule_type": "Planleggingstype",
|
||||
"interval_days": "Intervall (dager)",
|
||||
"warning_days": "Varslingsdager",
|
||||
"earliest_completion_days": "Earliest completion (days before due)",
|
||||
"earliest_completion_days_help": "Leave empty to allow completing any time. 0 = only on/after the due date.",
|
||||
"last_performed_optional": "Sist utført (valgfritt)",
|
||||
"interval_anchor": "Intervallforankring",
|
||||
"anchor_completion": "Fra fullføringsdato",
|
||||
"anchor_planned": "Fra planlagt dato (ingen avdrift)",
|
||||
"edit_object": "Rediger objekt",
|
||||
"name": "Navn",
|
||||
"manufacturer_optional": "Produsent (valgfritt)",
|
||||
"model_optional": "Modell (valgfritt)",
|
||||
"serial_number_optional": "Serienummer (valgfritt)",
|
||||
"serial_number_label": "S/N",
|
||||
"documentation_url_label": "Bruksanvisning",
|
||||
"object_notes_label": "Notater",
|
||||
"sort_due_date": "Forfallsdato",
|
||||
"sort_object": "Objektnavn",
|
||||
"sort_type": "Type",
|
||||
"sort_task_name": "Oppgavenavn",
|
||||
"all_objects": "Alle objekter",
|
||||
"tasks_lower": "oppgaver",
|
||||
"no_tasks_yet": "Ingen oppgaver ennå",
|
||||
"add_first_task": "Legg til første oppgave",
|
||||
"trigger_configuration": "Utløserkonfigurasjon",
|
||||
"entity_id": "Entitets-ID",
|
||||
"comma_separated": "kommaseparert",
|
||||
"entity_logic": "Entitetslogikk",
|
||||
"entity_logic_any": "Hvilken som helst entitet utløser",
|
||||
"entity_logic_all": "Alle entiteter må utløse",
|
||||
"entities": "entiteter",
|
||||
"attribute_optional": "Attributt (valgfritt, tomt = tilstand)",
|
||||
"use_entity_state": "Bruk entitetstilstand (ingen attributt)",
|
||||
"trigger_above": "Utløs over",
|
||||
"trigger_below": "Utløs under",
|
||||
"for_at_least_minutes": "I minst (minutter)",
|
||||
"safety_interval_days": "Sikkerhetsintervall (dager, valgfritt)",
|
||||
"safety_interval": "Sikkerhetsintervall (valgfritt)",
|
||||
"delta_mode": "Deltamodus",
|
||||
"from_state_optional": "Fra tilstand (valgfritt)",
|
||||
"to_state_optional": "Til tilstand (valgfritt)",
|
||||
"documentation_url_optional": "Dokumentasjons-URL (valgfritt)",
|
||||
"object_notes_optional": "Notater (valgfritt)",
|
||||
"nfc_tag_id_optional": "NFC-brikke-ID (valgfritt)",
|
||||
"nfc_tags_empty_help": "Ingen NFC-brikker registrert i Home Assistant ennå.",
|
||||
"nfc_tags_open_settings": "Åpne brikkeinnstillinger",
|
||||
"nfc_tags_refresh": "Oppdater",
|
||||
"environmental_entity_optional": "Miljøsensor (valgfritt)",
|
||||
"environmental_entity_helper": "f.eks. sensor.outdoor_temperature — justerer intervallet basert på miljøforhold",
|
||||
"environmental_attribute_optional": "Miljøattributt (valgfritt)",
|
||||
"nfc_tag_id": "NFC-brikke-ID",
|
||||
"nfc_linked": "NFC-brikke koblet",
|
||||
"nfc_link_hint": "Klikk for å koble NFC-brikke",
|
||||
"responsible_user": "Ansvarlig bruker",
|
||||
"shared_with": "Shared with (rotation)",
|
||||
"shared_with_help": "Pick multiple people to share this task; the responsible person rotates on each completion.",
|
||||
"rotation_strategy": "Rotation",
|
||||
"rotation_none": "No rotation",
|
||||
"rotation_round_robin": "Round-robin",
|
||||
"rotation_least_completed": "Least completed",
|
||||
"rotation_random": "Random",
|
||||
"no_user_assigned": "(Ingen bruker tildelt)",
|
||||
"all_users": "Alle brukere",
|
||||
"my_tasks": "Mine oppgaver",
|
||||
"tab_calendar": "Kalender",
|
||||
"cal_no_events": "Ingen vedlikehold",
|
||||
"cal_window_7": "7 dager",
|
||||
"cal_window_14": "14 dager",
|
||||
"cal_window_30": "30 dager",
|
||||
"cal_window_365": "1 år",
|
||||
"cal_every_n_days": "hver {n}. dag",
|
||||
"cal_source_time": "Tidsbasert",
|
||||
"cal_source_time_adaptive": "Tidsbasert (adaptiv)",
|
||||
"cal_source_sensor": "Sensorbasert",
|
||||
"cal_predicted": "forutsagt",
|
||||
"cal_confidence_high": "høy sikkerhet",
|
||||
"cal_confidence_medium": "middels sikkerhet",
|
||||
"cal_confidence_low": "lav sikkerhet",
|
||||
"budget_monthly": "Månedsbudsjett",
|
||||
"budget_yearly": "Årsbudsjett",
|
||||
"groups": "Grupper",
|
||||
"new_group": "Ny gruppe",
|
||||
"edit_group": "Rediger gruppe",
|
||||
"no_groups": "Ingen grupper ennå",
|
||||
"delete_group": "Slett gruppe",
|
||||
"delete_group_confirm": "Slette gruppen '{name}'?",
|
||||
"group_select_tasks": "Velg oppgaver",
|
||||
"group_name_required": "Navn er påkrevd",
|
||||
"description_optional": "Beskrivelse (valgfritt)",
|
||||
"selected": "Valgt",
|
||||
"loading_chart": "Laster diagramdata...",
|
||||
"hide_outliers": "Skjul avvik (sensorfeil)",
|
||||
"was_maintenance_needed": "Var dette vedlikeholdet nødvendig?",
|
||||
"feedback_needed": "Nødvendig",
|
||||
"feedback_not_needed": "Ikke nødvendig",
|
||||
"feedback_not_sure": "Usikker",
|
||||
"suggested_interval": "Foreslått intervall",
|
||||
"apply_suggestion": "Bruk",
|
||||
"reanalyze": "Analyser på nytt",
|
||||
"reanalyze_result": "Ny analyse",
|
||||
"reanalyze_insufficient_data": "Ikke nok data til å gi en anbefaling",
|
||||
"data_points": "datapunkter",
|
||||
"dismiss_suggestion": "Avvis",
|
||||
"confidence_low": "Lav",
|
||||
"confidence_medium": "Middels",
|
||||
"confidence_high": "Høy",
|
||||
"recommended": "anbefalt",
|
||||
"seasonal_awareness": "Sesongbevissthet",
|
||||
"edit_seasonal_overrides": "Rediger sesongfaktorer",
|
||||
"seasonal_overrides_title": "Sesongfaktorer (overstyring)",
|
||||
"seasonal_overrides_hint": "Faktor per måned (0,1–5,0). Tom = læres automatisk.",
|
||||
"seasonal_override_invalid": "Ugyldig verdi",
|
||||
"seasonal_override_range": "Faktor må være mellom 0,1 og 5,0",
|
||||
"clear_all": "Fjern alle",
|
||||
"seasonal_chart_title": "Sesongfaktorer",
|
||||
"seasonal_learned": "Lært",
|
||||
"seasonal_manual": "Manuell",
|
||||
"month_jan": "Jan",
|
||||
"month_feb": "Feb",
|
||||
"month_mar": "Mar",
|
||||
"month_apr": "Apr",
|
||||
"month_may": "Mai",
|
||||
"month_jun": "Jun",
|
||||
"month_jul": "Jul",
|
||||
"month_aug": "Aug",
|
||||
"month_sep": "Sep",
|
||||
"month_oct": "Okt",
|
||||
"month_nov": "Nov",
|
||||
"month_dec": "Des",
|
||||
"sensor_prediction": "Sensorprediksjon",
|
||||
"degradation_trend": "Trend",
|
||||
"trend_rising": "Stigende",
|
||||
"trend_falling": "Synkende",
|
||||
"trend_stable": "Stabil",
|
||||
"trend_insufficient_data": "Utilstrekkelige data",
|
||||
"days_until_threshold": "Dager til terskel",
|
||||
"threshold_exceeded": "Terskel overskredet",
|
||||
"environmental_adjustment": "Miljøfaktor",
|
||||
"sensor_prediction_urgency": "Sensoren forutsier terskel om ~{days} dager",
|
||||
"day_short": "dag",
|
||||
"weibull_reliability_curve": "Pålitelighetskurve",
|
||||
"weibull_failure_probability": "Feilsannsynlighet",
|
||||
"weibull_r_squared": "Tilpasning R²",
|
||||
"beta_early_failures": "Tidlige feil",
|
||||
"beta_random_failures": "Tilfeldige feil",
|
||||
"beta_wear_out": "Slitasje",
|
||||
"beta_highly_predictable": "Svært forutsigbar",
|
||||
"confidence_interval": "Konfidensintervall",
|
||||
"confidence_conservative": "Konservativ",
|
||||
"confidence_aggressive": "Optimistisk",
|
||||
"current_interval_marker": "Nåværende intervall",
|
||||
"recommended_marker": "Anbefalt",
|
||||
"characteristic_life": "Karakteristisk levetid",
|
||||
"chart_mini_sparkline": "Trend-minigraf",
|
||||
"chart_history": "Kostnads- og varighetshistorikk",
|
||||
"chart_seasonal": "Sesongfaktorer, 12 måneder",
|
||||
"chart_weibull": "Weibull-pålitelighetskurve",
|
||||
"chart_sparkline": "Diagram over sensorutløserverdi",
|
||||
"days_progress": "Dagsfremdrift",
|
||||
"qr_code": "QR-kode",
|
||||
"qr_generating": "Genererer QR-kode…",
|
||||
"qr_error": "Kunne ikke generere QR-kode.",
|
||||
"qr_error_no_url": "Ingen HA-URL konfigurert. Angi en ekstern eller intern URL under Innstillinger → System → Nettverk.",
|
||||
"save_error": "Kunne ikke lagre. Prøv igjen.",
|
||||
"qr_print": "Skriv ut",
|
||||
"qr_download": "Last ned SVG",
|
||||
"qr_action": "Handling ved skanning",
|
||||
"qr_action_view": "Vis vedlikeholdsinfo",
|
||||
"qr_action_complete": "Merk vedlikehold som fullført",
|
||||
"qr_url_mode": "Lenketype",
|
||||
"qr_mode_companion": "Companion-app",
|
||||
"qr_mode_local": "Lokal (mDNS)",
|
||||
"qr_mode_server": "Server-URL",
|
||||
"overview": "Oversikt",
|
||||
"analysis": "Analyse",
|
||||
"recent_activities": "Nylige aktiviteter",
|
||||
"search_notes": "Søk i notater",
|
||||
"avg_cost": "Gj.snittlig kostnad",
|
||||
"no_advanced_features": "Ingen avanserte funksjoner aktivert",
|
||||
"no_advanced_features_hint": "Aktiver «Adaptive intervaller» eller «Sesongmønstre» i integrasjonsinnstillingene for å se analysedata her.",
|
||||
"analysis_not_enough_data": "Ikke nok data for analyse ennå.",
|
||||
"analysis_not_enough_data_hint": "Weibull-analyse krever minst 5 fullførte vedlikehold; sesongmønstre blir synlige etter 6+ datapunkter per måned.",
|
||||
"analysis_manual_task_hint": "Manuelle oppgaver uten intervall genererer ikke analysedata.",
|
||||
"completions": "fullføringer",
|
||||
"current": "Nåværende",
|
||||
"shorter": "Kortere",
|
||||
"longer": "Lengre",
|
||||
"normal": "Normal",
|
||||
"disabled": "Deaktivert",
|
||||
"compound_logic": "Sammensatt logikk",
|
||||
"compound": "Sammensatt (flere betingelser)",
|
||||
"compound_logic_and": "OG — alle betingelser må utløses",
|
||||
"compound_logic_or": "ELLER — én betingelse er nok",
|
||||
"compound_help": "Kombiner flere sensorbetingelser til én utløser.",
|
||||
"compound_no_conditions": "Ingen betingelser ennå — legg til minst én.",
|
||||
"compound_add_condition": "Legg til betingelse",
|
||||
"compound_condition": "Betingelse",
|
||||
"compound_remove_condition": "Fjern betingelse",
|
||||
"card_title": "Tittel",
|
||||
"card_show_header": "Vis topptekst med statistikk",
|
||||
"card_show_actions": "Vis handlingsknapper",
|
||||
"card_compact": "Kompakt modus",
|
||||
"card_max_items": "Maks elementer (0 = alle)",
|
||||
"card_filter_status": "Filtrer etter status",
|
||||
"card_filter_status_help": "Tom = vis alle statuser.",
|
||||
"card_filter_objects": "Filtrer etter objekter",
|
||||
"card_filter_objects_help": "Tom = vis alle objekter.",
|
||||
"card_filter_entities": "Filtrer etter entiteter (entity_ids)",
|
||||
"card_filter_entities_help": "Velg sensor-/binary_sensor-entiteter fra denne integrasjonen. Tom = alle.",
|
||||
"card_loading_objects": "Laster objekter…",
|
||||
"card_load_error": "Kunne ikke laste objekter — kontroller WebSocket-tilkoblingen.",
|
||||
"card_no_tasks_title": "Ingen vedlikeholdsoppgaver ennå",
|
||||
"card_no_tasks_cta": "→ Opprett en i vedlikeholdspanelet",
|
||||
"no_objects": "Ingen objekter ennå.",
|
||||
"action_error": "Handlingen mislyktes. Prøv igjen.",
|
||||
"area_id_optional": "Område (valgfritt)",
|
||||
"installation_date_optional": "Installasjonsdato (valgfritt)",
|
||||
"warranty_expiry_optional": "Garanti utløper (valgfritt)",
|
||||
"warranty": "Garanti",
|
||||
"warranty_valid_until": "gyldig til {date}",
|
||||
"warranty_expires_in": "utløper om {days} dager",
|
||||
"warranty_expired": "utløpt",
|
||||
"cal_past_windows": "Tidligere vinduer",
|
||||
"cal_forward_windows": "Fremtidige vinduer",
|
||||
"history_edit_title": "Rediger historikkoppføring",
|
||||
"history_edit_timestamp": "Tidsstempel",
|
||||
"manufacturer": "Produsent",
|
||||
"model": "Modell",
|
||||
"area": "Område",
|
||||
"actions": "Handlinger",
|
||||
"view_mode_label": "Visning",
|
||||
"view_cards": "Kortvisning",
|
||||
"view_table": "Tabellvisning",
|
||||
"objects_table_columns_label": "Kolonner i objekttabell",
|
||||
"objects_table_columns_hint": "Velg hvilke kolonner som vises i tabellvisningen for objekter.",
|
||||
"custom_icon_optional": "Ikon (valgfritt, f.eks. mdi:wrench)",
|
||||
"task_enabled": "Oppgave aktivert",
|
||||
"skip_reason_prompt": "Hoppe over denne oppgaven?",
|
||||
"reason_optional": "Årsak (valgfritt)",
|
||||
"reset_date_prompt": "Merke oppgaven som utført?",
|
||||
"reset_date_optional": "Sist utført-dato (valgfritt, standard er i dag)",
|
||||
"notes_label": "Notater",
|
||||
"documentation_label": "Dokumentasjon",
|
||||
"no_nfc_tag": "— Ingen brikke —",
|
||||
"dashboard": "Dashbord",
|
||||
"tab_today": "I dag",
|
||||
"palette_placeholder": "Søk objekter og oppgaver…",
|
||||
"palette_no_results": "Ingen treff",
|
||||
"palette_hint": "↑↓ naviger · Enter åpne · Esc lukk",
|
||||
"today_all_caught_up": "Alt er ajour! Ingenting forfaller denne uken.",
|
||||
"today_overdue": "Forfalt",
|
||||
"today_due_today": "Forfaller i dag",
|
||||
"today_this_week": "Denne uken",
|
||||
"settings": "Innstillinger",
|
||||
"settings_features": "Avanserte funksjoner",
|
||||
"settings_features_desc": "Aktiver eller deaktiver avanserte funksjoner. Deaktivering skjuler dem fra grensesnittet, men sletter ikke data.",
|
||||
"feat_adaptive": "Adaptiv planlegging",
|
||||
"feat_adaptive_desc": "Lær optimale intervaller fra vedlikeholdshistorikken",
|
||||
"feat_predictions": "Sensorprediksjoner",
|
||||
"feat_predictions_desc": "Forutsi utløserdatoer fra sensorforringelse",
|
||||
"feat_seasonal": "Sesongjusteringer",
|
||||
"feat_seasonal_desc": "Juster intervaller basert på sesongmønstre",
|
||||
"feat_environmental": "Miljøkorrelasjon",
|
||||
"feat_environmental_desc": "Korreler intervaller med temperatur/fuktighet",
|
||||
"feat_budget": "Budsjettsporing",
|
||||
"feat_budget_desc": "Spor månedlige og årlige vedlikeholdsutgifter",
|
||||
"feat_groups": "Oppgavegrupper",
|
||||
"feat_groups_desc": "Organiser oppgaver i logiske grupper",
|
||||
"feat_checklists": "Sjekklister",
|
||||
"feat_checklists_desc": "Flertrinnsprosedyrer for fullføring av oppgaver",
|
||||
"settings_general": "Generelt",
|
||||
"settings_default_warning": "Standard varslingsdager",
|
||||
"settings_panel_enabled": "Sidepanel",
|
||||
"settings_panel_title": "Tittel på sidepanel",
|
||||
"settings_notifications": "Varsler",
|
||||
"settings_notify_service": "Varslingstjeneste",
|
||||
"test_notification": "Testvarsel",
|
||||
"send_test": "Send test",
|
||||
"testing": "Sender…",
|
||||
"test_notification_success": "Testvarsel sendt",
|
||||
"test_notification_failed": "Testvarsel mislyktes",
|
||||
"settings_notify_due_soon": "Varsle når noe forfaller snart",
|
||||
"settings_notify_overdue": "Varsle når noe er forfalt",
|
||||
"settings_notify_triggered": "Varsle når noe utløses",
|
||||
"settings_interval_hours": "Gjentaksintervall (timer, 0 = én gang)",
|
||||
"settings_quiet_hours": "Stilletimer",
|
||||
"settings_quiet_start": "Start",
|
||||
"settings_quiet_end": "Slutt",
|
||||
"settings_max_per_day": "Maks varsler per dag (0 = ubegrenset)",
|
||||
"settings_bundling": "Samle varsler",
|
||||
"settings_bundle_threshold": "Samlingsterskel",
|
||||
"settings_reminder_leads": "Extra reminders (days before due)",
|
||||
"settings_reminder_leads_hint": "Comma-separated lead times, e.g. 14, 3, 0 — one extra reminder fires on each matching day. Empty = off.",
|
||||
"settings_actions": "Handlingsknapper på mobil",
|
||||
"settings_action_complete": "Vis «Fullfør»-knapp",
|
||||
"settings_action_skip": "Vis «Hopp over»-knapp",
|
||||
"settings_action_snooze": "Vis «Slumre»-knapp",
|
||||
"settings_weekly_digest": "Ukentlig oversikt",
|
||||
"settings_weekly_digest_hint": "Ett sammendragsvarsel mandag morgen når oppgaver forfaller.",
|
||||
"settings_warranty_reminder": "Warranty expiry reminder",
|
||||
"settings_warranty_reminder_days": "Days before expiry",
|
||||
"settings_warranty_reminder_hint": "Notify once when an object's warranty is this many days from expiring.",
|
||||
"settings_snooze_hours": "Slumrevarighet (timer)",
|
||||
"settings_budget": "Budsjett",
|
||||
"settings_currency": "Valuta",
|
||||
"settings_budget_monthly": "Månedsbudsjett",
|
||||
"settings_budget_yearly": "Årsbudsjett",
|
||||
"settings_budget_alerts": "Budsjettvarsler",
|
||||
"settings_budget_threshold": "Varslingsterskel (%)",
|
||||
"settings_import_export": "Import / eksport",
|
||||
"settings_export_json": "Eksporter JSON",
|
||||
"settings_export_yaml": "Eksporter YAML",
|
||||
"settings_export_csv": "Eksporter CSV",
|
||||
"settings_import_csv": "Importer CSV",
|
||||
"settings_import_placeholder": "Lim inn JSON- eller CSV-innhold her…",
|
||||
"settings_import_btn": "Importer",
|
||||
"settings_import_success": "{count} objekter importert.",
|
||||
"settings_export_success": "Eksport lastet ned.",
|
||||
"settings_saved": "Innstilling lagret.",
|
||||
"settings_include_history": "Inkluder historikk",
|
||||
"sort_alphabetical": "Alfabetisk",
|
||||
"sort_due_soonest": "Forfaller først",
|
||||
"sort_task_count": "Antall oppgaver",
|
||||
"sort_area": "Område",
|
||||
"sort_assigned_user": "Tildelt bruker",
|
||||
"sort_group": "Gruppe",
|
||||
"groupby_none": "Ingen gruppering",
|
||||
"groupby_area": "Etter område",
|
||||
"groupby_group": "Etter gruppe",
|
||||
"groupby_user": "Etter bruker",
|
||||
"filter_label": "Filter",
|
||||
"user_label": "Bruker",
|
||||
"sort_label": "Sorter",
|
||||
"group_by_label": "Grupper etter",
|
||||
"state_value_help": "Bruk HA-tilstandsverdien (vanligvis små bokstaver, f.eks. \"on\"/\"off\"). Store/små bokstaver normaliseres ved lagring.",
|
||||
"target_changes_help": "Antall samsvarende overganger før utløseren aktiveres (standard: 1).",
|
||||
"qr_print_title": "Skriv ut QR-koder",
|
||||
"qr_print_desc": "Generer en utskrivbar side med QR-koder som kan klippes ut og festes på utstyret ditt.",
|
||||
"qr_print_load": "Last objekter",
|
||||
"qr_print_filter": "Filter",
|
||||
"qr_print_objects": "Objekter",
|
||||
"qr_print_actions": "Handlinger",
|
||||
"qr_print_url_mode": "Lenketype",
|
||||
"qr_print_estimate": "Estimerte QR-koder",
|
||||
"qr_print_over_limit": "grensen er 200, snevre inn filteret",
|
||||
"qr_print_generate": "Generer QR-koder",
|
||||
"qr_print_generating": "Genererer…",
|
||||
"qr_print_ready": "QR-koder klare",
|
||||
"qr_print_print_button": "Skriv ut",
|
||||
"qr_print_empty": "Ingenting å generere",
|
||||
"qr_action_skip": "Hopp over",
|
||||
"vacation_title": "Feriemodus",
|
||||
"vacation_active": "aktiv",
|
||||
"vacation_ended": "avsluttet",
|
||||
"vacation_desc": "Planlegg en ferie: varsler settes på pause i perioden pluss en buffer på noen dager. Du kan inkludere bestemte oppgaver igjen.",
|
||||
"vacation_enable": "Aktiver feriemodus",
|
||||
"vacation_start": "Start",
|
||||
"vacation_end": "Slutt",
|
||||
"vacation_buffer": "Buffer (dager)",
|
||||
"vacation_exempt_title": "Varsle likevel under ferie",
|
||||
"vacation_exempt_desc": "Velg oppgaver som fortsatt skal varsle under ferie (f.eks. kritisk bassengkjemi).",
|
||||
"vacation_load_tasks": "Last oppgaver",
|
||||
"vacation_preview_btn": "Vis forhåndsvisning",
|
||||
"vacation_preview_affected": "oppgaver berørt",
|
||||
"vacation_event_due_soon": "forfaller snart",
|
||||
"vacation_event_overdue": "blir forfalt",
|
||||
"vacation_event_triggered_est": "sensorutløsning mulig",
|
||||
"vacation_sensor_based": "(sensorbasert)",
|
||||
"vacation_action_notify": "Varsle likevel",
|
||||
"vacation_action_unsilence": "Demp igjen",
|
||||
"vacation_marked_complete": "Merket som fullført",
|
||||
"vacation_marked_skip": "Hoppet over",
|
||||
"vacation_end_now": "Avslutt ferie nå",
|
||||
"add": "Legg til",
|
||||
"show_stats": "Vis statistikk + grafer",
|
||||
"hide_stats": "Skjul statistikk",
|
||||
"adaptive_no_data": "Ikke nok fullføringshistorikk ennå for adaptiv analyse. Fullfør denne oppgaven noen flere ganger for å låse opp intervallanbefalinger og pålitelighetsdiagrammer.",
|
||||
"suggestion_applied": "Foreslått intervall tatt i bruk",
|
||||
"vacation_mode": "Feriemodus",
|
||||
"vacation_status_active": "Aktiv nå",
|
||||
"vacation_status_scheduled": "Planlagt",
|
||||
"vacation_status_inactive": "Inaktiv",
|
||||
"vacation_end_now_confirm": "Avslutte ferien umiddelbart?",
|
||||
"vacation_exempt_count": "unntatt",
|
||||
"vacation_advanced": "Avansert…",
|
||||
"vacation_open_panel": "Åpne i panel",
|
||||
"enable": "Aktiver",
|
||||
"saved": "Lagret",
|
||||
"budget_monthly_set": "Angi månedlig",
|
||||
"budget_yearly_set": "Angi årlig",
|
||||
"budget_advanced": "Valuta, varsler…",
|
||||
"budget_open_panel": "Åpne i panel",
|
||||
"groups_empty": "Ingen grupper ennå.",
|
||||
"group_new_placeholder": "Legg til gruppe…",
|
||||
"group_delete_confirm": "Slette gruppen \"{name}\"?",
|
||||
"groups_manage_tasks": "Administrer oppgavetildelinger…",
|
||||
"groups_open_panel": "Åpne i panel",
|
||||
"unassigned": "Ikke tildelt",
|
||||
"no_area": "Intet område",
|
||||
"has_overdue": "Har forfalte oppgaver",
|
||||
"object": "Objekt",
|
||||
"settings_panel_access": "Paneltilgang",
|
||||
"settings_panel_access_desc": "Administratorer har alltid full tilgang. For å delegere opprettelse, redigering og sletting til bestemte ikke-administratorer, slå dette på og velg dem nedenfor — alle andre ser kun Fullfør og Hopp over.",
|
||||
"settings_operator_write": "Tillat valgte brukere å opprette, redigere og slette",
|
||||
"settings_operator_write_desc": "Av: bare administratorer kan endre innhold. På: de valgte brukerne nedenfor får også full tilgang.",
|
||||
"no_non_admin_users": "Ingen ikke-administratorbrukere funnet. Legg til noen under Innstillinger → Personer.",
|
||||
"owner_label": "Eier",
|
||||
"feat_completion_actions": "Fullføringshandlinger",
|
||||
"feat_completion_actions_desc": "HA-handling per oppgave ved fullføring + hurtigfullføring med QR og forhåndsinnstilte verdier.",
|
||||
"on_complete_action_title": "Ved fullføring: utløs HA-handling (valgfritt)",
|
||||
"on_complete_action_desc": "Kaller en HA-tjeneste når oppgaven fullføres — f.eks. tilbakestille en teller på enheten.",
|
||||
"on_complete_action_service": "Tjeneste",
|
||||
"on_complete_action_target": "Målentitet",
|
||||
"on_complete_action_target_hint": "Merk: entitetsdomenet må samsvare med tjenesten — f.eks. fungerer 'button.press' kun på button.*, 'counter.increment' kun på counter.*, 'input_button.press' kun på input_button.* osv. Ved manglende samsvar mislykkes handlingen i det stille (HA logger 'Referenced entities ... missing or not currently available').",
|
||||
"on_complete_action_data": "Data (JSON, valgfritt)",
|
||||
"on_complete_action_test": "Valider konfigurasjon",
|
||||
"on_complete_action_test_success": "✓ Konfigurasjon gyldig (handlingen utløses kun ved fullføring av oppgaven)",
|
||||
"on_complete_action_test_failed": "Mislyktes",
|
||||
"quick_complete_defaults_title": "Standardverdier for hurtigfullføring (for QR-skanninger, valgfritt)",
|
||||
"quick_complete_defaults_desc": "Forhåndsinnstilte verdier for hurtigfullføring via QR-skanning. Uten disse åpner QR-koden fullføringsdialogen.",
|
||||
"quick_complete_defaults_notes": "Notater",
|
||||
"quick_complete_defaults_cost": "Kostnad",
|
||||
"quick_complete_defaults_duration": "Varighet (minutter)",
|
||||
"quick_complete_defaults_feedback_none": "Ingen tilbakemelding",
|
||||
"quick_complete_defaults_feedback_needed": "Var nødvendig",
|
||||
"quick_complete_defaults_feedback_not_needed": "Ikke nødvendig",
|
||||
"quick_complete_success": "Raskt merket som fullført",
|
||||
"show_all_objects": "Vis alle objekter",
|
||||
"show_all_tasks": "Fjern filter — vis alle oppgaver",
|
||||
"filter_to_overdue": "Filtrer oppgavelisten til kun forfalte",
|
||||
"filter_to_due_soon": "Filtrer oppgavelisten til kun forfaller snart",
|
||||
"filter_to_triggered": "Filtrer oppgavelisten til kun utløste",
|
||||
"open_task": "Åpne oppgave",
|
||||
"show_details": "Vis historikk + statistikk",
|
||||
"hide_details": "Skjul detaljer",
|
||||
"history_empty": "Ingen historikk ennå.",
|
||||
"history_edit_button": "Rediger oppføring",
|
||||
"total_cost": "Total kostnad",
|
||||
"times_performed": "Utført",
|
||||
"older_entries": "eldre",
|
||||
"open_in_panel": "Åpne i vedlikeholdspanelet",
|
||||
"skip_reason": "Årsak for å hoppe over (valgfritt)",
|
||||
"reset_to_date": "Tilbakestill sist utført til",
|
||||
"delete_task_confirm": "Slette denne oppgaven og dens historikk?",
|
||||
"delete_object_confirm": "Slette dette objektet og alle dets oppgaver?",
|
||||
"loading": "Laster…",
|
||||
"archive": "Arkivér",
|
||||
"undo": "Angre",
|
||||
"task_archived": "Oppgave arkivert",
|
||||
"object_archived": "Objekt arkivert",
|
||||
"unarchive": "Gjenopprett",
|
||||
"archived": "Arkivert",
|
||||
"show_archived": "Vis arkiverte",
|
||||
"hide_archived": "Skjul arkiverte",
|
||||
"confirm_archive_object": "Arkivere dette objektet og oppgavene? Historikken beholdes og kan gjenopprettes senere.",
|
||||
"settings_archive": "Arkiv og oppbevaring",
|
||||
"settings_archive_desc": "Legg bort fullførte engangsoppgaver uten å slette dem. Arkiverte elementer er skjult og inaktive, men beholder historikk og kostnad.",
|
||||
"settings_archive_oneoff_days": "Arkivér fullførte engangsoppgaver automatisk etter (dager, 0 = av)",
|
||||
"settings_delete_archived_oneoff_days": "Slett arkiverte engangsoppgaver automatisk etter (dager, 0 = aldri)",
|
||||
"archive_object": "Arkivér objekt",
|
||||
"unarchive_object": "Gjenopprett objekt",
|
||||
"documents": "Dokumenter",
|
||||
"documents_empty": "Ingen dokumenter ennå.",
|
||||
"doc_upload": "Last opp fil",
|
||||
"doc_uploading": "Laster opp…",
|
||||
"doc_add_link": "Legg til lenke",
|
||||
"doc_link_url": "URL (https://…)",
|
||||
"doc_link_title": "Tittel (valgfritt)",
|
||||
"doc_open": "Åpne",
|
||||
"doc_delete_confirm": "Slette \"{name}\"?",
|
||||
"doc_too_large": "Filen er for stor (maks. 25 MB).",
|
||||
"doc_upload_failed": "Opplasting mislyktes.",
|
||||
"completion_photo_optional": "Completion photo (optional)",
|
||||
"add_photo": "Add photo",
|
||||
"uploading": "Uploading…",
|
||||
"remove": "Remove",
|
||||
"doc_deduped": "Allerede lagret et annet sted — delt, ingen ekstra plass.",
|
||||
"doc_dup_in_object": "Denne filen er allerede knyttet til dette objektet.",
|
||||
"doc_link_invalid": "Kun http/https-lenker er tillatt.",
|
||||
"doc_cat_manual": "Bruksanvisning",
|
||||
"doc_cat_warranty": "Garanti",
|
||||
"doc_cat_invoice": "Faktura",
|
||||
"doc_cat_spare_parts": "Reservedeler",
|
||||
"doc_cat_photo": "Foto",
|
||||
"doc_cat_other": "Annet",
|
||||
"doc_link_badge": "Lenke",
|
||||
"doc_storage_title": "Dokumentlagring",
|
||||
"doc_storage_saved": "Spart ved deduplisering",
|
||||
"doc_storage_refresh": "Oppdater",
|
||||
"doc_download": "Last ned",
|
||||
"doc_close": "Lukk",
|
||||
"doc_camera": "Ta bilde",
|
||||
"doc_drop_hint": "Slipp filer her",
|
||||
"doc_task_none": "Ingen dokumenter knyttet til denne oppgaven.",
|
||||
"doc_link_existing": "Knytt et dokument…",
|
||||
"doc_attach": "Knytt",
|
||||
"doc_unlink": "Fjern kobling",
|
||||
"doc_page": "Side",
|
||||
"chart_range_7d": "7d",
|
||||
"chart_range_30d": "30d",
|
||||
"chart_range_90d": "90d",
|
||||
"chart_range_1y": "1å",
|
||||
"chart_since_service": "siden siste vedlikehold",
|
||||
"chart_no_stats": "Ingen langtidsstatistikk for denne enheten — viser bare verdier fra vedlikeholdsoppføringer",
|
||||
"auto_complete_on_recovery": "Fullfør automatisk når sensoren normaliseres",
|
||||
"auto_complete_on_recovery_help": "Registrerer en fullføring (setter sist utført) når utløseren løser seg selv — f.eks. salt etterfylt, filter byttet.",
|
||||
"doc_search": "Søk i dokumenter…",
|
||||
"doc_search_none": "Ingen samsvarende dokumenter",
|
||||
"link_device_optional": "Koble til eksisterende enhet (valgfritt)",
|
||||
"parent_object_optional": "Overordnet objekt (valgfritt)",
|
||||
"parent_none": "(Ingen overordnet)",
|
||||
"paused": "Satt på pause",
|
||||
"pause_object": "Sett på pause",
|
||||
"resume_object": "Gjenoppta",
|
||||
"pause_until_prompt": "Frys objektets planer — ingenting forfaller og ingenting varsler før det gjenopptas. Angi eventuelt en dato for automatisk gjenopptakelse.",
|
||||
"pause_until_label": "Gjenoppta den (valgfritt)",
|
||||
"object_paused": "Objekt satt på pause",
|
||||
"object_resumed": "Objekt gjenopptatt — planer startet på nytt",
|
||||
"object_paused_badge": "På pause",
|
||||
"paused_until_label": "til",
|
||||
"replace_object": "Erstatt…",
|
||||
"replace_object_prompt": "Pensjoner dette objektet og opprett en etterfølger. Historikk og kostnader forblir arkivert på det gamle; oppgaver og dokumenter følger med til det nye, tellere starter på nytt.",
|
||||
"replace_name_label": "Etterfølgerens navn",
|
||||
"object_replaced": "Objekt erstattet — etterfølger opprettet",
|
||||
"reading_unit_label": "Avlesningsenhet (f.eks. kWh, m³)",
|
||||
"reading_unit_help": "Vises ved siden av den registrerte verdien når oppgaven fullføres.",
|
||||
"reading_value_label": "Avlest verdi",
|
||||
"reading_label": "Avlesning",
|
||||
"settings_templates_label": "Malgalleri",
|
||||
"settings_templates_hint": "Fjern haken for maler du aldri trenger — de forsvinner fra \"Fra mal\"-velgerne (panel og config flow). Ellers endres ingenting; kan aktiveres igjen når som helst.",
|
||||
"worksheet": "Arbeidsark",
|
||||
"worksheet_scan_view": "Skann for å åpne oppgaven",
|
||||
"worksheet_scan_complete": "Skann for å fullføre",
|
||||
"worksheet_manual_excerpt": "Utdrag fra manualen",
|
||||
"worksheet_pages": "sider",
|
||||
"worksheet_printed": "Skrevet ut",
|
||||
"worksheet_never": "Aldri"
|
||||
}
|
||||
@@ -0,0 +1,696 @@
|
||||
{
|
||||
"maintenance": "Onderhoud",
|
||||
"objects": "Objecten",
|
||||
"tasks": "Taken",
|
||||
"overdue": "Achterstallig",
|
||||
"due_soon": "Binnenkort",
|
||||
"triggered": "Geactiveerd",
|
||||
"ok": "OK",
|
||||
"all": "Alle",
|
||||
"new_object": "+ Nieuw object",
|
||||
"templates_from": "Uit sjabloon",
|
||||
"templates_title": "Begin met een sjabloon",
|
||||
"templates_task_count": "{n} taken",
|
||||
"template_created": "Aangemaakt uit sjabloon",
|
||||
"onboard_hint": "Voeg je eerste object toe om onderhoud te volgen.",
|
||||
"edit": "Bewerken",
|
||||
"duplicate": "Dupliceren",
|
||||
"task_duplicated": "Taak gedupliceerd",
|
||||
"object_duplicated": "Object gedupliceerd",
|
||||
"delete": "Verwijderen",
|
||||
"add_task": "+ Taak",
|
||||
"complete": "Voltooid",
|
||||
"completed": "Voltooid",
|
||||
"skip": "Overslaan",
|
||||
"skipped": "Overgeslagen",
|
||||
"missed": "Missed",
|
||||
"reset": "Resetten",
|
||||
"snooze": "Snooze",
|
||||
"snoozed": "Snoozed",
|
||||
"cancel": "Annuleren",
|
||||
"bulk_select": "Selecteren",
|
||||
"bulk_select_all": "Alles selecteren",
|
||||
"bulk_n_selected": "{n} geselecteerd",
|
||||
"bulk_completed": "{n} taken voltooid",
|
||||
"bulk_archived": "{n} taken gearchiveerd",
|
||||
"completing": "Wordt voltooid…",
|
||||
"interval": "Interval",
|
||||
"warning": "Waarschuwing",
|
||||
"last_performed": "Laatst uitgevoerd",
|
||||
"next_due": "Volgende keer",
|
||||
"days_until_due": "Dagen tot vervaldatum",
|
||||
"avg_duration": "Ø Duur",
|
||||
"trigger": "Trigger",
|
||||
"trigger_type": "Triggertype",
|
||||
"threshold_above": "Bovengrens",
|
||||
"threshold_below": "Ondergrens",
|
||||
"threshold": "Drempelwaarde",
|
||||
"counter": "Teller",
|
||||
"state_change": "Statuswijziging",
|
||||
"runtime": "Looptijd",
|
||||
"runtime_hours": "Doellooptijd (uren)",
|
||||
"target_value": "Doelwaarde",
|
||||
"baseline": "Basislijn",
|
||||
"target_changes": "Doelwijzigingen",
|
||||
"for_minutes": "Voor (minuten)",
|
||||
"time_based": "Tijdgebaseerd",
|
||||
"sensor_based": "Sensorgebaseerd",
|
||||
"manual": "Handmatig",
|
||||
"one_time": "Eenmalig",
|
||||
"weekdays": "Weekdagen",
|
||||
"nth_weekday": "Zoveelste weekdag van de maand",
|
||||
"day_of_month": "Dag van de maand",
|
||||
"recurrence_on_days": "Herhalen op",
|
||||
"recurrence_occurrence": "Voorkomen",
|
||||
"recurrence_weekday": "Weekdag",
|
||||
"recurrence_day": "Dag van de maand (1–31)",
|
||||
"recurrence_last_day": "Last day of the month",
|
||||
"recurrence_business_day": "Business days only (roll back from weekend)",
|
||||
"recurrence_offset": "Offset (days, ±)",
|
||||
"recurrence_offset_help": "Shift the date by ±N days, e.g. -2 = two days before.",
|
||||
"last_day_month": "Last day of month",
|
||||
"last_business_day_month": "Last business day",
|
||||
"ord_1": "1e",
|
||||
"ord_2": "2e",
|
||||
"ord_3": "3e",
|
||||
"ord_4": "4e",
|
||||
"ord_5": "5e",
|
||||
"ord_last": "Laatste",
|
||||
"day_word": "Dag",
|
||||
"interval_value": "Interval",
|
||||
"interval_unit": "Eenheid",
|
||||
"unit_days": "Dagen",
|
||||
"unit_weeks": "Weken",
|
||||
"unit_months": "Maanden",
|
||||
"unit_years": "Jaren",
|
||||
"due_date": "Vervaldatum",
|
||||
"cleaning": "Reiniging",
|
||||
"inspection": "Inspectie",
|
||||
"replacement": "Vervanging",
|
||||
"calibration": "Kalibratie",
|
||||
"service": "Service",
|
||||
"reading": "Aflezing",
|
||||
"custom": "Aangepast",
|
||||
"history": "Geschiedenis",
|
||||
"cost": "Kosten",
|
||||
"report_button": "Rapport",
|
||||
"report_title": "Onderhoudsrapport",
|
||||
"report_generated": "Gegenereerd",
|
||||
"report_times_done": "Gedaan",
|
||||
"report_total_cost": "Totale kosten",
|
||||
"report_every": "elke {n} {unit}",
|
||||
"report_notes": "Notities",
|
||||
"report_col_type": "Type",
|
||||
"report_col_status": "Status",
|
||||
"report_col_schedule": "Schema",
|
||||
"duration": "Duur",
|
||||
"both": "Beide",
|
||||
"trigger_val": "Triggerwaarde",
|
||||
"complete_title": "Voltooid: ",
|
||||
"checklist": "Checklist",
|
||||
"checklist_steps_optional": "Checklist-stappen (optioneel)",
|
||||
"checklist_placeholder": "Filter schoonmaken\nPakking vervangen\nDruk testen",
|
||||
"checklist_help": "Eén stap per regel. Max. 100 items.",
|
||||
"err_too_long": "{field}: te lang (max. {n} tekens)",
|
||||
"err_too_short": "{field}: te kort (min. {n} tekens)",
|
||||
"err_value_too_high": "{field}: te groot (max. {n})",
|
||||
"err_value_too_low": "{field}: te klein (min. {n})",
|
||||
"err_required": "{field}: verplicht",
|
||||
"err_wrong_type": "{field}: verkeerd type (verwacht: {type})",
|
||||
"err_invalid_choice": "{field}: niet-toegestane waarde",
|
||||
"err_invalid_value": "{field}: ongeldige waarde",
|
||||
"feat_schedule_time": "Tijd-van-dag-planning",
|
||||
"feat_schedule_time_desc": "Taken vervallen op een specifieke tijd in plaats van middernacht.",
|
||||
"schedule_time_optional": "Vervaldagstijd (optioneel, HH:MM)",
|
||||
"schedule_time_help": "Leeg = middernacht (standaard). HA-tijdzone.",
|
||||
"at_time": "om",
|
||||
"notes_optional": "Notities (optioneel)",
|
||||
"cost_optional": "Kosten (optioneel)",
|
||||
"duration_minutes": "Duur in minuten (optioneel)",
|
||||
"days": "dagen",
|
||||
"day": "dag",
|
||||
"today": "Vandaag",
|
||||
"d_overdue": "d achterstallig",
|
||||
"no_tasks": "Geen onderhoudstaken. Maak een object aan om te beginnen.",
|
||||
"no_tasks_short": "Geen taken",
|
||||
"no_history": "Nog geen geschiedenisitems.",
|
||||
"show_all": "Alles tonen",
|
||||
"cost_duration_chart": "Kosten & Duur",
|
||||
"installed": "Geïnstalleerd",
|
||||
"confirm_delete_object": "Dit object en alle bijbehorende taken verwijderen?",
|
||||
"confirm_delete_task": "Deze taak verwijderen?",
|
||||
"min": "Min",
|
||||
"max": "Max",
|
||||
"save": "Opslaan",
|
||||
"saving": "Opslaan…",
|
||||
"edit_task": "Taak bewerken",
|
||||
"new_task": "Nieuwe onderhoudstaak",
|
||||
"task_name": "Taaknaam",
|
||||
"maintenance_type": "Onderhoudstype",
|
||||
"priority": "Prioriteit",
|
||||
"labels": "Labels",
|
||||
"labels_placeholder": "e.g. safety, seasonal, tenant-visible",
|
||||
"labels_help": "Comma-separated tags for filtering and reporting.",
|
||||
"priority_low": "Laag",
|
||||
"priority_normal": "Normaal",
|
||||
"priority_high": "Hoog",
|
||||
"schedule_type": "Planningstype",
|
||||
"interval_days": "Interval (dagen)",
|
||||
"warning_days": "Waarschuwingsdagen",
|
||||
"earliest_completion_days": "Earliest completion (days before due)",
|
||||
"earliest_completion_days_help": "Leave empty to allow completing any time. 0 = only on/after the due date.",
|
||||
"last_performed_optional": "Laatst uitgevoerd (optioneel)",
|
||||
"interval_anchor": "Interval-anker",
|
||||
"anchor_completion": "Vanaf voltooiing",
|
||||
"anchor_planned": "Vanaf geplande datum (geen drift)",
|
||||
"edit_object": "Object bewerken",
|
||||
"name": "Naam",
|
||||
"manufacturer_optional": "Fabrikant (optioneel)",
|
||||
"model_optional": "Model (optioneel)",
|
||||
"serial_number_optional": "Serienummer (optioneel)",
|
||||
"serial_number_label": "S/N",
|
||||
"documentation_url_label": "Handleiding",
|
||||
"object_notes_label": "Notities",
|
||||
"sort_due_date": "Vervaldatum",
|
||||
"sort_object": "Objectnaam",
|
||||
"sort_type": "Type",
|
||||
"sort_task_name": "Taaknaam",
|
||||
"all_objects": "Alle objecten",
|
||||
"tasks_lower": "taken",
|
||||
"no_tasks_yet": "Nog geen taken",
|
||||
"add_first_task": "Eerste taak toevoegen",
|
||||
"trigger_configuration": "Triggerconfiguratie",
|
||||
"entity_id": "Entiteits-ID",
|
||||
"comma_separated": "kommagescheiden",
|
||||
"entity_logic": "Entiteitslogica",
|
||||
"entity_logic_any": "Elke entiteit triggert",
|
||||
"entity_logic_all": "Alle entiteiten moeten triggeren",
|
||||
"entities": "entiteiten",
|
||||
"attribute_optional": "Attribuut (optioneel, leeg = status)",
|
||||
"use_entity_state": "Entiteitsstatus gebruiken (geen attribuut)",
|
||||
"trigger_above": "Activeren als boven",
|
||||
"trigger_below": "Activeren als onder",
|
||||
"for_at_least_minutes": "Voor minstens (minuten)",
|
||||
"safety_interval_days": "Veiligheidsinterval (dagen, optioneel)",
|
||||
"safety_interval": "Veiligheidsinterval (optioneel)",
|
||||
"delta_mode": "Deltamodus",
|
||||
"from_state_optional": "Van status (optioneel)",
|
||||
"to_state_optional": "Naar status (optioneel)",
|
||||
"documentation_url_optional": "Documentatie-URL (optioneel)",
|
||||
"object_notes_optional": "Notities (optioneel)",
|
||||
"nfc_tag_id_optional": "NFC-tag-ID (optioneel)",
|
||||
"nfc_tags_empty_help": "Nog geen NFC-tags geregistreerd in Home Assistant.",
|
||||
"nfc_tags_open_settings": "Tag-instellingen openen",
|
||||
"nfc_tags_refresh": "Vernieuwen",
|
||||
"environmental_entity_optional": "Omgevingssensor (optioneel)",
|
||||
"environmental_entity_helper": "bv. sensor.buitentemperatuur — past het interval aan op basis van omgevingswaarden",
|
||||
"environmental_attribute_optional": "Omgevingsattribuut (optioneel)",
|
||||
"nfc_tag_id": "NFC-tag-ID",
|
||||
"nfc_linked": "NFC-tag gekoppeld",
|
||||
"nfc_link_hint": "Klik om NFC-tag te koppelen",
|
||||
"responsible_user": "Verantwoordelijke gebruiker",
|
||||
"shared_with": "Shared with (rotation)",
|
||||
"shared_with_help": "Pick multiple people to share this task; the responsible person rotates on each completion.",
|
||||
"rotation_strategy": "Rotation",
|
||||
"rotation_none": "No rotation",
|
||||
"rotation_round_robin": "Round-robin",
|
||||
"rotation_least_completed": "Least completed",
|
||||
"rotation_random": "Random",
|
||||
"no_user_assigned": "(Geen gebruiker toegewezen)",
|
||||
"all_users": "Alle gebruikers",
|
||||
"my_tasks": "Mijn taken",
|
||||
"tab_calendar": "Kalender",
|
||||
"cal_no_events": "Geen onderhoud",
|
||||
"cal_window_7": "7 dagen",
|
||||
"cal_window_14": "14 dagen",
|
||||
"cal_window_30": "30 dagen",
|
||||
"cal_window_365": "1 jaar",
|
||||
"cal_every_n_days": "elke {n} dagen",
|
||||
"cal_source_time": "Tijd-gebaseerd",
|
||||
"cal_source_time_adaptive": "Tijd-gebaseerd (adaptief)",
|
||||
"cal_source_sensor": "Sensor-gebaseerd",
|
||||
"cal_predicted": "voorspeld",
|
||||
"cal_confidence_high": "hoge zekerheid",
|
||||
"cal_confidence_medium": "gemiddelde zekerheid",
|
||||
"cal_confidence_low": "lage zekerheid",
|
||||
"budget_monthly": "Maandbudget",
|
||||
"budget_yearly": "Jaarbudget",
|
||||
"groups": "Groepen",
|
||||
"new_group": "Nieuwe groep",
|
||||
"edit_group": "Groep bewerken",
|
||||
"no_groups": "Nog geen groepen",
|
||||
"delete_group": "Groep verwijderen",
|
||||
"delete_group_confirm": "Groep '{name}' verwijderen?",
|
||||
"group_select_tasks": "Taken selecteren",
|
||||
"group_name_required": "Naam vereist",
|
||||
"description_optional": "Beschrijving (optioneel)",
|
||||
"selected": "Geselecteerd",
|
||||
"loading_chart": "Grafiekgegevens laden...",
|
||||
"hide_outliers": "Uitschieters verbergen (sensorfouten)",
|
||||
"was_maintenance_needed": "Was dit onderhoud nodig?",
|
||||
"feedback_needed": "Nodig",
|
||||
"feedback_not_needed": "Niet nodig",
|
||||
"feedback_not_sure": "Niet zeker",
|
||||
"suggested_interval": "Voorgesteld interval",
|
||||
"apply_suggestion": "Toepassen",
|
||||
"reanalyze": "Opnieuw analyseren",
|
||||
"reanalyze_result": "Nieuwe analyse",
|
||||
"reanalyze_insufficient_data": "Onvoldoende gegevens voor een aanbeveling",
|
||||
"data_points": "datapunten",
|
||||
"dismiss_suggestion": "Negeren",
|
||||
"confidence_low": "Laag",
|
||||
"confidence_medium": "Gemiddeld",
|
||||
"confidence_high": "Hoog",
|
||||
"recommended": "aanbevolen",
|
||||
"seasonal_awareness": "Seizoensbewustzijn",
|
||||
"edit_seasonal_overrides": "Seizoensfactoren bewerken",
|
||||
"seasonal_overrides_title": "Seizoensfactoren (override)",
|
||||
"seasonal_overrides_hint": "Factor per maand (0.1–5.0). Leeg = automatisch geleerd.",
|
||||
"seasonal_override_invalid": "Ongeldige waarde",
|
||||
"seasonal_override_range": "Factor moet tussen 0.1 en 5.0 liggen",
|
||||
"clear_all": "Alles wissen",
|
||||
"seasonal_chart_title": "Seizoensfactoren",
|
||||
"seasonal_learned": "Geleerd",
|
||||
"seasonal_manual": "Handmatig",
|
||||
"month_jan": "Jan",
|
||||
"month_feb": "Feb",
|
||||
"month_mar": "Mrt",
|
||||
"month_apr": "Apr",
|
||||
"month_may": "Mei",
|
||||
"month_jun": "Jun",
|
||||
"month_jul": "Jul",
|
||||
"month_aug": "Aug",
|
||||
"month_sep": "Sep",
|
||||
"month_oct": "Okt",
|
||||
"month_nov": "Nov",
|
||||
"month_dec": "Dec",
|
||||
"sensor_prediction": "Sensorvoorspelling",
|
||||
"degradation_trend": "Trend",
|
||||
"trend_rising": "Stijgend",
|
||||
"trend_falling": "Dalend",
|
||||
"trend_stable": "Stabiel",
|
||||
"trend_insufficient_data": "Onvoldoende gegevens",
|
||||
"days_until_threshold": "Dagen tot drempelwaarde",
|
||||
"threshold_exceeded": "Drempelwaarde overschreden",
|
||||
"environmental_adjustment": "Omgevingsfactor",
|
||||
"sensor_prediction_urgency": "Sensor voorspelt drempelwaarde in ~{days} dagen",
|
||||
"day_short": "dag",
|
||||
"weibull_reliability_curve": "Betrouwbaarheidscurve",
|
||||
"weibull_failure_probability": "Faalkans",
|
||||
"weibull_r_squared": "Fit R²",
|
||||
"beta_early_failures": "Vroege uitval",
|
||||
"beta_random_failures": "Willekeurige uitval",
|
||||
"beta_wear_out": "Slijtage",
|
||||
"beta_highly_predictable": "Zeer voorspelbaar",
|
||||
"confidence_interval": "Betrouwbaarheidsinterval",
|
||||
"confidence_conservative": "Conservatief",
|
||||
"confidence_aggressive": "Optimistisch",
|
||||
"current_interval_marker": "Huidig interval",
|
||||
"recommended_marker": "Aanbevolen",
|
||||
"characteristic_life": "Karakteristieke levensduur",
|
||||
"chart_mini_sparkline": "Trend-sparkline",
|
||||
"chart_history": "Kosten- en duurgeschiedenis",
|
||||
"chart_seasonal": "Seizoensfactoren, 12 maanden",
|
||||
"chart_weibull": "Weibull-betrouwbaarheidscurve",
|
||||
"chart_sparkline": "Sensor-triggerwaardegrafiek",
|
||||
"days_progress": "Dagenvoortgang",
|
||||
"qr_code": "QR-code",
|
||||
"qr_generating": "QR-code genereren…",
|
||||
"qr_error": "QR-code kon niet worden gegenereerd.",
|
||||
"qr_error_no_url": "Geen HA-URL geconfigureerd. Stel een externe of interne URL in via Instellingen → Systeem → Netwerk.",
|
||||
"save_error": "Opslaan mislukt. Probeer het opnieuw.",
|
||||
"qr_print": "Afdrukken",
|
||||
"qr_download": "SVG downloaden",
|
||||
"qr_action": "Actie bij scannen",
|
||||
"qr_action_view": "Onderhoudsinfo bekijken",
|
||||
"qr_action_complete": "Onderhoud als voltooid markeren",
|
||||
"qr_url_mode": "Linktype",
|
||||
"qr_mode_companion": "Companion App",
|
||||
"qr_mode_local": "Lokaal (mDNS)",
|
||||
"qr_mode_server": "Server-URL",
|
||||
"overview": "Overzicht",
|
||||
"analysis": "Analyse",
|
||||
"recent_activities": "Recente activiteiten",
|
||||
"search_notes": "Notities doorzoeken",
|
||||
"avg_cost": "Ø Kosten",
|
||||
"no_advanced_features": "Geen geavanceerde functies ingeschakeld",
|
||||
"no_advanced_features_hint": "Schakel „Adaptieve Intervallen” of „Seizoenpatronen” in via de integratie-instellingen om hier analysegegevens te zien.",
|
||||
"analysis_not_enough_data": "Nog niet genoeg gegevens voor analyse.",
|
||||
"analysis_not_enough_data_hint": "Weibull-analyse vereist minstens 5 voltooide onderhoudsbeurten; seizoenspatronen worden zichtbaar na 6+ datapunten per maand.",
|
||||
"analysis_manual_task_hint": "Handmatige taken zonder interval genereren geen analysegegevens.",
|
||||
"completions": "voltooiingen",
|
||||
"current": "Huidig",
|
||||
"shorter": "Korter",
|
||||
"longer": "Langer",
|
||||
"normal": "Normaal",
|
||||
"disabled": "Uitgeschakeld",
|
||||
"compound_logic": "Samengestelde logica",
|
||||
"compound": "Samengesteld (meerdere voorwaarden)",
|
||||
"compound_logic_and": "EN — alle voorwaarden moeten activeren",
|
||||
"compound_logic_or": "OF — één voorwaarde is genoeg",
|
||||
"compound_help": "Combineer meerdere sensorvoorwaarden tot één trigger.",
|
||||
"compound_no_conditions": "Nog geen voorwaarden — voeg er ten minste één toe.",
|
||||
"compound_add_condition": "Voorwaarde toevoegen",
|
||||
"compound_condition": "Voorwaarde",
|
||||
"compound_remove_condition": "Voorwaarde verwijderen",
|
||||
"card_title": "Titel",
|
||||
"card_show_header": "Koptekst met statistieken tonen",
|
||||
"card_show_actions": "Actieknoppen tonen",
|
||||
"card_compact": "Compacte modus",
|
||||
"card_max_items": "Max items (0 = alle)",
|
||||
"card_filter_status": "Filteren op status",
|
||||
"card_filter_status_help": "Leeg = alle statussen tonen.",
|
||||
"card_filter_objects": "Filteren op objecten",
|
||||
"card_filter_objects_help": "Leeg = alle objecten tonen.",
|
||||
"card_filter_entities": "Filteren op entiteiten (entity_ids)",
|
||||
"card_filter_entities_help": "Kies sensor/binary_sensor entiteiten van deze integratie. Leeg = alle.",
|
||||
"card_loading_objects": "Objecten laden…",
|
||||
"card_load_error": "Objecten konden niet worden geladen — controleer de WebSocket-verbinding.",
|
||||
"card_no_tasks_title": "Nog geen onderhoudstaken",
|
||||
"card_no_tasks_cta": "→ Maak er een aan in het Maintenance-paneel",
|
||||
"no_objects": "Nog geen objecten.",
|
||||
"action_error": "Actie mislukt. Probeer het opnieuw.",
|
||||
"area_id_optional": "Gebied (optioneel)",
|
||||
"installation_date_optional": "Installatiedatum (optioneel)",
|
||||
"warranty_expiry_optional": "Garantie verloopt (optioneel)",
|
||||
"warranty": "Garantie",
|
||||
"warranty_valid_until": "geldig tot {date}",
|
||||
"warranty_expires_in": "verloopt over {days} dagen",
|
||||
"warranty_expired": "verlopen",
|
||||
"cal_past_windows": "Eerdere vensters",
|
||||
"cal_forward_windows": "Toekomstige vensters",
|
||||
"history_edit_title": "Geschiedenisitem bewerken",
|
||||
"history_edit_timestamp": "Tijdstempel",
|
||||
"manufacturer": "Fabrikant",
|
||||
"model": "Model",
|
||||
"area": "Gebied",
|
||||
"actions": "Acties",
|
||||
"view_mode_label": "Weergave",
|
||||
"view_cards": "Kaartweergave",
|
||||
"view_table": "Tabelweergave",
|
||||
"objects_table_columns_label": "Kolommen objecttabel",
|
||||
"objects_table_columns_hint": "Kies welke kolommen in de tabelweergave van objecten verschijnen.",
|
||||
"custom_icon_optional": "Icoon (optioneel, bijv. mdi:wrench)",
|
||||
"task_enabled": "Taak ingeschakeld",
|
||||
"skip_reason_prompt": "Deze taak overslaan?",
|
||||
"reason_optional": "Reden (optioneel)",
|
||||
"reset_date_prompt": "Taak markeren als uitgevoerd?",
|
||||
"reset_date_optional": "Laatste uitvoeringsdatum (optioneel, standaard vandaag)",
|
||||
"notes_label": "Notities",
|
||||
"documentation_label": "Documentatie",
|
||||
"no_nfc_tag": "— Geen tag —",
|
||||
"dashboard": "Dashboard",
|
||||
"tab_today": "Vandaag",
|
||||
"palette_placeholder": "Objecten en taken zoeken…",
|
||||
"palette_no_results": "Geen resultaten",
|
||||
"palette_hint": "↑↓ navigeren · Enter openen · Esc sluiten",
|
||||
"today_all_caught_up": "Helemaal bij! Niets te doen deze week.",
|
||||
"today_overdue": "Achterstallig",
|
||||
"today_due_today": "Vandaag te doen",
|
||||
"today_this_week": "Deze week",
|
||||
"settings": "Instellingen",
|
||||
"settings_features": "Geavanceerde functies",
|
||||
"settings_features_desc": "Schakel geavanceerde functies in of uit. Uitschakelen verbergt ze in de interface maar verwijdert geen gegevens.",
|
||||
"feat_adaptive": "Adaptieve planning",
|
||||
"feat_adaptive_desc": "Leer optimale intervallen uit onderhoudsgeschiedenis",
|
||||
"feat_predictions": "Sensorvoorspellingen",
|
||||
"feat_predictions_desc": "Voorspel triggerdatums op basis van sensordegradatie",
|
||||
"feat_seasonal": "Seizoensaanpassingen",
|
||||
"feat_seasonal_desc": "Pas intervallen aan op seizoenspatronen",
|
||||
"feat_environmental": "Omgevingscorrelatie",
|
||||
"feat_environmental_desc": "Correleer intervallen met temperatuur/vochtigheid",
|
||||
"feat_budget": "Budgetbeheer",
|
||||
"feat_budget_desc": "Volg maandelijkse en jaarlijkse onderhoudsuitgaven",
|
||||
"feat_groups": "Taakgroepen",
|
||||
"feat_groups_desc": "Organiseer taken in logische groepen",
|
||||
"feat_checklists": "Checklists",
|
||||
"feat_checklists_desc": "Meerstaps procedures voor taakvoltooiing",
|
||||
"settings_general": "Algemeen",
|
||||
"settings_default_warning": "Standaard waarschuwingsdagen",
|
||||
"settings_panel_enabled": "Zijbalkpaneel",
|
||||
"settings_panel_title": "Titel zijbalkpaneel",
|
||||
"settings_notifications": "Meldingen",
|
||||
"settings_notify_service": "Meldingsservice",
|
||||
"test_notification": "Testmelding",
|
||||
"send_test": "Test versturen",
|
||||
"testing": "Verzenden…",
|
||||
"test_notification_success": "Testmelding verzonden",
|
||||
"test_notification_failed": "Testmelding mislukt",
|
||||
"settings_notify_due_soon": "Melding bij bijna verlopen",
|
||||
"settings_notify_overdue": "Melding bij achterstallig",
|
||||
"settings_notify_triggered": "Melding bij geactiveerd",
|
||||
"settings_interval_hours": "Herhalingsinterval (uren, 0 = eenmalig)",
|
||||
"settings_quiet_hours": "Stille uren",
|
||||
"settings_quiet_start": "Start",
|
||||
"settings_quiet_end": "Einde",
|
||||
"settings_max_per_day": "Max meldingen per dag (0 = onbeperkt)",
|
||||
"settings_bundling": "Meldingen bundelen",
|
||||
"settings_bundle_threshold": "Bundeldrempel",
|
||||
"settings_reminder_leads": "Extra reminders (days before due)",
|
||||
"settings_reminder_leads_hint": "Comma-separated lead times, e.g. 14, 3, 0 — one extra reminder fires on each matching day. Empty = off.",
|
||||
"settings_actions": "Mobiele actieknoppen",
|
||||
"settings_action_complete": "Knop 'Voltooid' tonen",
|
||||
"settings_action_skip": "Knop 'Overslaan' tonen",
|
||||
"settings_action_snooze": "Knop 'Snooze' tonen",
|
||||
"settings_weekly_digest": "Wekelijks overzicht",
|
||||
"settings_weekly_digest_hint": "Eén samenvattingsmelding op maandagochtend als er taken zijn.",
|
||||
"settings_warranty_reminder": "Warranty expiry reminder",
|
||||
"settings_warranty_reminder_days": "Days before expiry",
|
||||
"settings_warranty_reminder_hint": "Notify once when an object's warranty is this many days from expiring.",
|
||||
"settings_snooze_hours": "Snoozeduur (uren)",
|
||||
"settings_budget": "Budget",
|
||||
"settings_currency": "Valuta",
|
||||
"settings_budget_monthly": "Maandbudget",
|
||||
"settings_budget_yearly": "Jaarbudget",
|
||||
"settings_budget_alerts": "Budgetwaarschuwingen",
|
||||
"settings_budget_threshold": "Waarschuwingsdrempel (%)",
|
||||
"settings_import_export": "Import / Export",
|
||||
"settings_export_json": "JSON exporteren",
|
||||
"settings_export_yaml": "YAML exporteren",
|
||||
"settings_export_csv": "CSV exporteren",
|
||||
"settings_import_csv": "CSV importeren",
|
||||
"settings_import_placeholder": "Plak JSON- of CSV-inhoud hier…",
|
||||
"settings_import_btn": "Importeren",
|
||||
"settings_import_success": "{count} objecten succesvol geïmporteerd.",
|
||||
"settings_export_success": "Export gedownload.",
|
||||
"settings_saved": "Instelling opgeslagen.",
|
||||
"settings_include_history": "Geschiedenis meenemen",
|
||||
"sort_alphabetical": "Alfabetisch",
|
||||
"sort_due_soonest": "Eerst vervallend",
|
||||
"sort_task_count": "Aantal taken",
|
||||
"sort_area": "Gebied",
|
||||
"sort_assigned_user": "Toegewezen gebruiker",
|
||||
"sort_group": "Groep",
|
||||
"groupby_none": "Geen groepering",
|
||||
"groupby_area": "Per gebied",
|
||||
"groupby_group": "Per groep",
|
||||
"groupby_user": "Per gebruiker",
|
||||
"filter_label": "Filter",
|
||||
"user_label": "Gebruiker",
|
||||
"sort_label": "Sorteren",
|
||||
"group_by_label": "Groeperen op",
|
||||
"state_value_help": "Gebruik de HA-statuswaarde (meestal in kleine letters, bv. \"on\"/\"off\"). Hoofdletters worden bij opslaan genormaliseerd.",
|
||||
"target_changes_help": "Aantal overeenkomende overgangen voordat de trigger wordt geactiveerd (standaard: 1).",
|
||||
"qr_print_title": "QR-codes afdrukken",
|
||||
"qr_print_desc": "Genereer een afdrukpagina met QR-codes om uit te knippen en op je apparaten te plakken.",
|
||||
"qr_print_load": "Objecten laden",
|
||||
"qr_print_filter": "Filter",
|
||||
"qr_print_objects": "Objecten",
|
||||
"qr_print_actions": "Acties",
|
||||
"qr_print_url_mode": "Linktype",
|
||||
"qr_print_estimate": "Geschatte QR-codes",
|
||||
"qr_print_over_limit": "max is 200, beperk de filter",
|
||||
"qr_print_generate": "QR-codes genereren",
|
||||
"qr_print_generating": "Genereren…",
|
||||
"qr_print_ready": "QR-codes klaar",
|
||||
"qr_print_print_button": "Afdrukken",
|
||||
"qr_print_empty": "Niets te genereren",
|
||||
"qr_action_skip": "Overslaan",
|
||||
"vacation_title": "Vakantiemodus",
|
||||
"vacation_active": "actief",
|
||||
"vacation_ended": "beëindigd",
|
||||
"vacation_desc": "Plan je vakantie: meldingen worden gepauzeerd tijdens de periode plus een buffer van dagen. Je kunt per taak uitzonderingen instellen.",
|
||||
"vacation_enable": "Vakantiemodus inschakelen",
|
||||
"vacation_start": "Begin",
|
||||
"vacation_end": "Einde",
|
||||
"vacation_buffer": "Buffer (dagen)",
|
||||
"vacation_exempt_title": "Toch melden tijdens vakantie",
|
||||
"vacation_exempt_desc": "Kies taken die ook tijdens vakantie meldingen moeten geven (bv. kritische zwembadchemie).",
|
||||
"vacation_load_tasks": "Taken laden",
|
||||
"vacation_preview_btn": "Voorvertoning",
|
||||
"vacation_preview_affected": "taken betrokken",
|
||||
"vacation_event_due_soon": "wordt binnenkort verschuldigd",
|
||||
"vacation_event_overdue": "wordt achterstallig",
|
||||
"vacation_event_triggered_est": "sensortrigger mogelijk",
|
||||
"vacation_sensor_based": "(sensor-gebaseerd)",
|
||||
"vacation_action_notify": "Toch melden",
|
||||
"vacation_action_unsilence": "Weer dempen",
|
||||
"vacation_marked_complete": "Als voltooid gemarkeerd",
|
||||
"vacation_marked_skip": "Overgeslagen",
|
||||
"vacation_end_now": "Vakantie nu beëindigen",
|
||||
"unassigned": "Niet toegewezen",
|
||||
"no_area": "Geen gebied",
|
||||
"has_overdue": "Heeft achterstallige taken",
|
||||
"object": "Object",
|
||||
"settings_panel_access": "Paneel-toegang",
|
||||
"settings_panel_access_desc": "Admins hebben altijd volledige toegang. Om aanmaken, bewerken en verwijderen aan specifieke niet-admins te delegeren, schakel dit in en selecteer hen hieronder — alle anderen zien alleen Voltooien en Overslaan.",
|
||||
"settings_operator_write": "Geselecteerde gebruikers laten aanmaken, bewerken en verwijderen",
|
||||
"settings_operator_write_desc": "Uit: alleen admins kunnen inhoud wijzigen. Aan: de geselecteerde gebruikers hieronder krijgen ook volledige toegang.",
|
||||
"no_non_admin_users": "Geen niet-admin gebruikers gevonden. Voeg ze toe in Instellingen → Personen.",
|
||||
"owner_label": "Eigenaar",
|
||||
"feat_completion_actions": "Voltooiings-acties",
|
||||
"feat_completion_actions_desc": "Per taak HA-actie bij voltooien + snel-voltooien-QR met vooraf ingestelde waarden.",
|
||||
"on_complete_action_title": "Bij voltooien: HA-actie uitvoeren (optioneel)",
|
||||
"on_complete_action_desc": "Roept een HA-service aan wanneer de taak is voltooid — bv. een teller op het apparaat resetten.",
|
||||
"on_complete_action_service": "Service",
|
||||
"on_complete_action_target": "Doel-entiteit",
|
||||
"on_complete_action_data": "Data (JSON, optioneel)",
|
||||
"on_complete_action_test": "Actie testen",
|
||||
"on_complete_action_test_success": "Geslaagd",
|
||||
"on_complete_action_test_failed": "Mislukt",
|
||||
"quick_complete_defaults_title": "Snel-voltooien-standaardwaarden (voor QR-scans, optioneel)",
|
||||
"quick_complete_defaults_desc": "Vooraf ingestelde waarden voor snel-voltooien-QR. Zonder deze opent de QR de voltooi-dialoog.",
|
||||
"quick_complete_defaults_notes": "Notities",
|
||||
"quick_complete_defaults_cost": "Kosten",
|
||||
"quick_complete_defaults_duration": "Duur (minuten)",
|
||||
"quick_complete_defaults_feedback_none": "Geen feedback",
|
||||
"quick_complete_defaults_feedback_needed": "Was nodig",
|
||||
"quick_complete_defaults_feedback_not_needed": "Niet nodig",
|
||||
"quick_complete_success": "Snel als voltooid gemarkeerd",
|
||||
"trigger_replaced": "Trigger vervangen",
|
||||
"add": "Toevoegen",
|
||||
"show_stats": "Statistieken + grafieken tonen",
|
||||
"hide_stats": "Statistieken verbergen",
|
||||
"adaptive_no_data": "Nog niet genoeg voltooiingsgeschiedenis voor adaptieve analyse. Voer deze taak nog een paar keer uit om intervaladviezen en betrouwbaarheidsgrafieken te ontgrendelen.",
|
||||
"suggestion_applied": "Voorgesteld interval toegepast",
|
||||
"vacation_mode": "Vakantiemodus",
|
||||
"vacation_status_active": "Nu actief",
|
||||
"vacation_status_scheduled": "Gepland",
|
||||
"vacation_status_inactive": "Inactief",
|
||||
"vacation_end_now_confirm": "Vakantie onmiddellijk beëindigen?",
|
||||
"vacation_exempt_count": "uitgezonderd",
|
||||
"vacation_advanced": "Geavanceerd…",
|
||||
"vacation_open_panel": "Openen in paneel",
|
||||
"enable": "Inschakelen",
|
||||
"saved": "Opgeslagen",
|
||||
"budget_monthly_set": "Maandelijks instellen",
|
||||
"budget_yearly_set": "Jaarlijks instellen",
|
||||
"budget_advanced": "Valuta, waarschuwingen…",
|
||||
"budget_open_panel": "Openen in paneel",
|
||||
"groups_empty": "Nog geen groepen.",
|
||||
"group_new_placeholder": "Groep toevoegen…",
|
||||
"group_delete_confirm": "Groep \"{name}\" verwijderen?",
|
||||
"groups_manage_tasks": "Taaktoewijzingen beheren…",
|
||||
"groups_open_panel": "Openen in paneel",
|
||||
"on_complete_action_target_hint": "Let op: het domein van de entiteit moet bij de service passen — bijv. 'button.press' werkt alleen op button.*, 'counter.increment' alleen op counter.*, 'input_button.press' alleen op input_button.* enz. Bij een mismatch mislukt de actie stil (HA logt 'Referenced entities ... missing or not currently available').",
|
||||
"show_all_objects": "Alle objecten tonen",
|
||||
"show_all_tasks": "Filter wissen — alle taken tonen",
|
||||
"filter_to_overdue": "Takenlijst filteren op alleen achterstallig",
|
||||
"filter_to_due_soon": "Takenlijst filteren op alleen binnenkort verschuldigd",
|
||||
"filter_to_triggered": "Takenlijst filteren op alleen geactiveerd",
|
||||
"open_task": "Taak openen",
|
||||
"show_details": "Geschiedenis + statistieken tonen",
|
||||
"hide_details": "Details verbergen",
|
||||
"history_empty": "Nog geen geschiedenis.",
|
||||
"history_edit_button": "Item bewerken",
|
||||
"total_cost": "Totale kosten",
|
||||
"times_performed": "Uitgevoerd",
|
||||
"older_entries": "ouder",
|
||||
"open_in_panel": "Openen in Onderhoudspaneel",
|
||||
"skip_reason": "Reden voor overslaan (optioneel)",
|
||||
"reset_to_date": "Laatst uitgevoerd terugzetten op",
|
||||
"delete_task_confirm": "Deze taak en de geschiedenis verwijderen?",
|
||||
"delete_object_confirm": "Dit object en alle taken verwijderen?",
|
||||
"loading": "Laden…",
|
||||
"archive": "Archiveren",
|
||||
"undo": "Ongedaan maken",
|
||||
"task_archived": "Taak gearchiveerd",
|
||||
"object_archived": "Object gearchiveerd",
|
||||
"unarchive": "Dearchiveren",
|
||||
"archived": "Gearchiveerd",
|
||||
"show_archived": "Gearchiveerde tonen",
|
||||
"hide_archived": "Gearchiveerde verbergen",
|
||||
"confirm_archive_object": "Dit object en zijn taken archiveren? De geschiedenis blijft behouden en kan later worden hersteld.",
|
||||
"settings_archive": "Archief & bewaring",
|
||||
"settings_archive_desc": "Voltooide eenmalige taken archiveren in plaats van verwijderen. Gearchiveerde items zijn verborgen en inactief, maar behouden geschiedenis en kosten.",
|
||||
"settings_archive_oneoff_days": "Voltooide eenmalige taken automatisch archiveren na (dagen, 0 = uit)",
|
||||
"settings_delete_archived_oneoff_days": "Gearchiveerde eenmalige taken automatisch verwijderen na (dagen, 0 = nooit)",
|
||||
"archive_object": "Object archiveren",
|
||||
"unarchive_object": "Object dearchiveren",
|
||||
"documents": "Documenten",
|
||||
"documents_empty": "Nog geen documenten.",
|
||||
"doc_upload": "Bestand uploaden",
|
||||
"doc_uploading": "Uploaden…",
|
||||
"doc_add_link": "Link toevoegen",
|
||||
"doc_link_url": "URL (https://…)",
|
||||
"doc_link_title": "Titel (optioneel)",
|
||||
"doc_open": "Openen",
|
||||
"doc_delete_confirm": "\"{name}\" verwijderen?",
|
||||
"doc_too_large": "Bestand is te groot (max. 25 MB).",
|
||||
"doc_upload_failed": "Uploaden mislukt.",
|
||||
"completion_photo_optional": "Completion photo (optional)",
|
||||
"add_photo": "Add photo",
|
||||
"uploading": "Uploading…",
|
||||
"remove": "Remove",
|
||||
"doc_deduped": "Al elders opgeslagen — gedeeld, geen extra ruimte.",
|
||||
"doc_dup_in_object": "Dit bestand is al aan dit object gekoppeld.",
|
||||
"doc_link_invalid": "Alleen http/https-links zijn toegestaan.",
|
||||
"doc_cat_manual": "Handleiding",
|
||||
"doc_cat_warranty": "Garantie",
|
||||
"doc_cat_invoice": "Factuur",
|
||||
"doc_cat_spare_parts": "Onderdelen",
|
||||
"doc_cat_photo": "Foto",
|
||||
"doc_cat_other": "Overig",
|
||||
"doc_link_badge": "Link",
|
||||
"doc_storage_title": "Documentopslag",
|
||||
"doc_storage_saved": "Bespaard door deduplicatie",
|
||||
"doc_storage_refresh": "Vernieuwen",
|
||||
"doc_download": "Downloaden",
|
||||
"doc_close": "Sluiten",
|
||||
"doc_camera": "Foto maken",
|
||||
"doc_drop_hint": "Sleep bestanden hierheen",
|
||||
"doc_task_none": "Geen documenten aan deze taak gekoppeld.",
|
||||
"doc_link_existing": "Document koppelen…",
|
||||
"doc_attach": "Koppelen",
|
||||
"doc_unlink": "Ontkoppelen",
|
||||
"doc_page": "Pagina",
|
||||
"chart_range_7d": "7d",
|
||||
"chart_range_30d": "30d",
|
||||
"chart_range_90d": "90d",
|
||||
"chart_range_1y": "1j",
|
||||
"chart_since_service": "sinds laatste onderhoud",
|
||||
"chart_no_stats": "Geen langetermijnstatistieken voor deze entiteit — alleen waarden uit onderhoudsregels worden getoond",
|
||||
"auto_complete_on_recovery": "Automatisch voltooien wanneer de sensor herstelt",
|
||||
"auto_complete_on_recovery_help": "Registreert een voltooiing (stelt laatst uitgevoerd in) wanneer de trigger zichzelf oplost — bijv. zout bijgevuld, filter vervangen.",
|
||||
"doc_search": "Documenten zoeken…",
|
||||
"doc_search_none": "Geen overeenkomende documenten",
|
||||
"link_device_optional": "Koppelen aan bestaand apparaat (optioneel)",
|
||||
"parent_object_optional": "Bovenliggend object (optioneel)",
|
||||
"parent_none": "(Geen bovenliggend object)",
|
||||
"paused": "Gepauzeerd",
|
||||
"pause_object": "Pauzeren",
|
||||
"resume_object": "Hervatten",
|
||||
"pause_until_prompt": "Bevries de schema's van dit object — niets wordt verwacht en niets meldt tot het wordt hervat. Stel optioneel een datum voor automatisch hervatten in.",
|
||||
"pause_until_label": "Hervatten op (optioneel)",
|
||||
"object_paused": "Object gepauzeerd",
|
||||
"object_resumed": "Object hervat — schema's herstart",
|
||||
"object_paused_badge": "Gepauzeerd",
|
||||
"paused_until_label": "tot",
|
||||
"replace_object": "Vervangen…",
|
||||
"replace_object_prompt": "Dit object uitfaseren en een opvolger aanmaken. Geschiedenis en kosten blijven gearchiveerd bij het oude; taken en documenten gaan mee naar het nieuwe, tellers beginnen opnieuw.",
|
||||
"replace_name_label": "Naam van de opvolger",
|
||||
"object_replaced": "Object vervangen — opvolger aangemaakt",
|
||||
"reading_unit_label": "Meeteenheid (bijv. kWh, m³)",
|
||||
"reading_unit_help": "Wordt naast de vastgelegde waarde getoond bij het afronden van deze taak.",
|
||||
"reading_value_label": "Meterstand",
|
||||
"reading_label": "Meting",
|
||||
"settings_templates_label": "Sjabloongalerij",
|
||||
"settings_templates_hint": "Vink sjablonen uit die je nooit nodig hebt — ze verdwijnen uit de \"Vanuit sjabloon\"-keuzes (paneel en config flow). Verder verandert er niets; altijd weer in te schakelen.",
|
||||
"worksheet": "Werkblad",
|
||||
"worksheet_scan_view": "Scan om de taak te openen",
|
||||
"worksheet_scan_complete": "Scan om af te ronden",
|
||||
"worksheet_manual_excerpt": "Handleidingfragment",
|
||||
"worksheet_pages": "pagina's",
|
||||
"worksheet_printed": "Afgedrukt",
|
||||
"worksheet_never": "Nooit"
|
||||
}
|
||||
@@ -0,0 +1,696 @@
|
||||
{
|
||||
"maintenance": "Konserwacja",
|
||||
"objects": "Obiekty",
|
||||
"tasks": "Zadania",
|
||||
"overdue": "Zaległe",
|
||||
"due_soon": "Wkrótce",
|
||||
"triggered": "Wyzwolone",
|
||||
"ok": "OK",
|
||||
"all": "Wszystkie",
|
||||
"new_object": "+ Nowy obiekt",
|
||||
"templates_from": "Z szablonu",
|
||||
"templates_title": "Zacznij od szablonu",
|
||||
"templates_task_count": "{n} zadań",
|
||||
"template_created": "Utworzono z szablonu",
|
||||
"onboard_hint": "Dodaj pierwszy obiekt, aby śledzić konserwację.",
|
||||
"edit": "Edytuj",
|
||||
"duplicate": "Duplikuj",
|
||||
"task_duplicated": "Zadanie zduplikowane",
|
||||
"object_duplicated": "Obiekt zduplikowany",
|
||||
"delete": "Usuń",
|
||||
"add_task": "+ Dodaj zadanie",
|
||||
"complete": "Wykonaj",
|
||||
"completed": "Wykonano",
|
||||
"skip": "Pomiń",
|
||||
"skipped": "Pominięte",
|
||||
"missed": "Missed",
|
||||
"reset": "Resetuj",
|
||||
"snooze": "Snooze",
|
||||
"snoozed": "Snoozed",
|
||||
"cancel": "Anuluj",
|
||||
"bulk_select": "Zaznacz",
|
||||
"bulk_select_all": "Zaznacz wszystko",
|
||||
"bulk_n_selected": "Zaznaczono {n}",
|
||||
"bulk_completed": "Ukończono {n} zadań",
|
||||
"bulk_archived": "Zarchiwizowano {n} zadań",
|
||||
"completing": "Wykonywanie…",
|
||||
"interval": "Interwał",
|
||||
"warning": "Ostrzeżenie",
|
||||
"last_performed": "Ostatnio wykonane",
|
||||
"next_due": "Następny termin",
|
||||
"days_until_due": "Dni do terminu",
|
||||
"avg_duration": "Śr. czas trwania",
|
||||
"trigger": "Wyzwalacz",
|
||||
"trigger_type": "Typ wyzwalacza",
|
||||
"threshold_above": "Górny limit",
|
||||
"threshold_below": "Dolny limit",
|
||||
"threshold": "Próg",
|
||||
"counter": "Licznik",
|
||||
"state_change": "Zmiana stanu",
|
||||
"runtime": "Czas pracy",
|
||||
"runtime_hours": "Docelowy czas pracy (godziny)",
|
||||
"target_value": "Wartość docelowa",
|
||||
"baseline": "Wartość bazowa",
|
||||
"target_changes": "Docelowa liczba zmian",
|
||||
"for_minutes": "Przez (minuty)",
|
||||
"time_based": "Czasowy",
|
||||
"sensor_based": "Oparty na czujniku",
|
||||
"manual": "Ręczny",
|
||||
"one_time": "Jednorazowo",
|
||||
"weekdays": "Dni tygodnia",
|
||||
"nth_weekday": "N-ty dzień tygodnia w miesiącu",
|
||||
"day_of_month": "Dzień miesiąca",
|
||||
"recurrence_on_days": "Powtarzaj w",
|
||||
"recurrence_occurrence": "Wystąpienie",
|
||||
"recurrence_weekday": "Dzień tygodnia",
|
||||
"recurrence_day": "Dzień miesiąca (1–31)",
|
||||
"recurrence_last_day": "Last day of the month",
|
||||
"recurrence_business_day": "Business days only (roll back from weekend)",
|
||||
"recurrence_offset": "Offset (days, ±)",
|
||||
"recurrence_offset_help": "Shift the date by ±N days, e.g. -2 = two days before.",
|
||||
"last_day_month": "Last day of month",
|
||||
"last_business_day_month": "Last business day",
|
||||
"ord_1": "1.",
|
||||
"ord_2": "2.",
|
||||
"ord_3": "3.",
|
||||
"ord_4": "4.",
|
||||
"ord_5": "5.",
|
||||
"ord_last": "Ostatni",
|
||||
"day_word": "Dzień",
|
||||
"interval_value": "Interwał",
|
||||
"interval_unit": "Jednostka",
|
||||
"unit_days": "Dni",
|
||||
"unit_weeks": "Tygodnie",
|
||||
"unit_months": "Miesiące",
|
||||
"unit_years": "Lata",
|
||||
"due_date": "Termin",
|
||||
"cleaning": "Czyszczenie",
|
||||
"inspection": "Inspekcja",
|
||||
"replacement": "Wymiana",
|
||||
"calibration": "Kalibracja",
|
||||
"service": "Serwis",
|
||||
"reading": "Odczyt",
|
||||
"custom": "Niestandardowy",
|
||||
"history": "Historia",
|
||||
"cost": "Koszt",
|
||||
"report_button": "Raport",
|
||||
"report_title": "Raport konserwacji",
|
||||
"report_generated": "Wygenerowano",
|
||||
"report_times_done": "Wykonano",
|
||||
"report_total_cost": "Koszt całkowity",
|
||||
"report_every": "co {n} {unit}",
|
||||
"report_notes": "Notatki",
|
||||
"report_col_type": "Typ",
|
||||
"report_col_status": "Status",
|
||||
"report_col_schedule": "Harmonogram",
|
||||
"duration": "Czas trwania",
|
||||
"both": "Oba",
|
||||
"trigger_val": "Wartość wyzwalacza",
|
||||
"complete_title": "Wykonaj: ",
|
||||
"checklist": "Lista kontrolna",
|
||||
"checklist_steps_optional": "Kroki listy kontrolnej (opcjonalne)",
|
||||
"checklist_placeholder": "Wyczyść filtr\nWymień uszczelkę\nSprawdź ciśnienie",
|
||||
"checklist_help": "Jeden krok na linię. Maks. 100 elementów.",
|
||||
"err_too_long": "{field}: za długie (maks. {n} znaków)",
|
||||
"err_too_short": "{field}: za krótkie (min. {n} znaków)",
|
||||
"err_value_too_high": "{field}: za duże (maks. {n})",
|
||||
"err_value_too_low": "{field}: za małe (min. {n})",
|
||||
"err_required": "{field}: wymagane",
|
||||
"err_wrong_type": "{field}: zły typ (oczekiwano: {type})",
|
||||
"err_invalid_choice": "{field}: niedozwolona wartość",
|
||||
"err_invalid_value": "{field}: nieprawidłowa wartość",
|
||||
"feat_schedule_time": "Harmonogram według pory dnia",
|
||||
"feat_schedule_time_desc": "Zadania stają się zaległe o określonej porze dnia zamiast o północy.",
|
||||
"schedule_time_optional": "Termin o godzinie (opcjonalne, HH:MM)",
|
||||
"schedule_time_help": "Puste = północ (domyślnie). Strefa czasowa HA.",
|
||||
"at_time": "o",
|
||||
"notes_optional": "Notatki (opcjonalne)",
|
||||
"cost_optional": "Koszt (opcjonalne)",
|
||||
"duration_minutes": "Czas trwania w minutach (opcjonalne)",
|
||||
"days": "dni",
|
||||
"day": "dzień",
|
||||
"today": "Dzisiaj",
|
||||
"d_overdue": "d zaległe",
|
||||
"no_tasks": "Jeszcze brak zadań konserwacyjnych. Utwórz obiekt, aby zacząć.",
|
||||
"no_tasks_short": "Brak zadań",
|
||||
"no_history": "Jeszcze brak wpisów historii.",
|
||||
"show_all": "Pokaż wszystko",
|
||||
"cost_duration_chart": "Koszt i czas trwania",
|
||||
"installed": "Zainstalowane",
|
||||
"confirm_delete_object": "Usunąć ten obiekt i wszystkie jego zadania?",
|
||||
"confirm_delete_task": "Usunąć to zadanie?",
|
||||
"min": "Min",
|
||||
"max": "Maks",
|
||||
"save": "Zapisz",
|
||||
"saving": "Zapisywanie…",
|
||||
"edit_task": "Edytuj zadanie",
|
||||
"new_task": "Nowe zadanie konserwacyjne",
|
||||
"task_name": "Nazwa zadania",
|
||||
"maintenance_type": "Typ konserwacji",
|
||||
"priority": "Priorytet",
|
||||
"labels": "Labels",
|
||||
"labels_placeholder": "e.g. safety, seasonal, tenant-visible",
|
||||
"labels_help": "Comma-separated tags for filtering and reporting.",
|
||||
"priority_low": "Niski",
|
||||
"priority_normal": "Normalny",
|
||||
"priority_high": "Wysoki",
|
||||
"schedule_type": "Typ harmonogramu",
|
||||
"interval_days": "Interwał (dni)",
|
||||
"warning_days": "Dni ostrzeżenia",
|
||||
"earliest_completion_days": "Earliest completion (days before due)",
|
||||
"earliest_completion_days_help": "Leave empty to allow completing any time. 0 = only on/after the due date.",
|
||||
"last_performed_optional": "Ostatnio wykonane (opcjonalne)",
|
||||
"interval_anchor": "Punkt zaczepienia interwału",
|
||||
"anchor_completion": "Od daty wykonania",
|
||||
"anchor_planned": "Od daty planowanej (bez przesunięć)",
|
||||
"edit_object": "Edytuj obiekt",
|
||||
"name": "Nazwa",
|
||||
"manufacturer_optional": "Producent (opcjonalne)",
|
||||
"model_optional": "Model (opcjonalne)",
|
||||
"serial_number_optional": "Numer seryjny (opcjonalne)",
|
||||
"serial_number_label": "S/N",
|
||||
"documentation_url_label": "Instrukcja",
|
||||
"object_notes_label": "Notatki",
|
||||
"sort_due_date": "Termin",
|
||||
"sort_object": "Nazwa obiektu",
|
||||
"sort_type": "Typ",
|
||||
"sort_task_name": "Nazwa zadania",
|
||||
"all_objects": "Wszystkie obiekty",
|
||||
"tasks_lower": "zadań",
|
||||
"no_tasks_yet": "Jeszcze brak zadań",
|
||||
"add_first_task": "Dodaj pierwsze zadanie",
|
||||
"trigger_configuration": "Konfiguracja wyzwalacza",
|
||||
"entity_id": "ID encji",
|
||||
"comma_separated": "oddzielone przecinkami",
|
||||
"entity_logic": "Logika encji",
|
||||
"entity_logic_any": "Wyzwala dowolna encja",
|
||||
"entity_logic_all": "Wszystkie encje muszą wyzwolić",
|
||||
"entities": "encje",
|
||||
"attribute_optional": "Atrybut (opcjonalny, puste = stan)",
|
||||
"use_entity_state": "Użyj stanu encji (bez atrybutu)",
|
||||
"trigger_above": "Wyzwól powyżej",
|
||||
"trigger_below": "Wyzwól poniżej",
|
||||
"for_at_least_minutes": "Przez co najmniej (minuty)",
|
||||
"safety_interval_days": "Interwał bezpieczeństwa (dni, opcjonalny)",
|
||||
"safety_interval": "Interwał bezpieczeństwa (opcjonalny)",
|
||||
"delta_mode": "Tryb delta",
|
||||
"from_state_optional": "Ze stanu (opcjonalne)",
|
||||
"to_state_optional": "Do stanu (opcjonalne)",
|
||||
"documentation_url_optional": "URL dokumentacji (opcjonalne)",
|
||||
"object_notes_optional": "Notatki (opcjonalne)",
|
||||
"nfc_tag_id_optional": "ID tagu NFC (opcjonalne)",
|
||||
"nfc_tags_empty_help": "W Home Assistant nie zarejestrowano jeszcze tagów NFC.",
|
||||
"nfc_tags_open_settings": "Otwórz ustawienia tagów",
|
||||
"nfc_tags_refresh": "Odśwież",
|
||||
"environmental_entity_optional": "Czujnik środowiskowy (opcjonalne)",
|
||||
"environmental_entity_helper": "np. sensor.outdoor_temperature — dostosowuje interwał na podstawie warunków środowiskowych",
|
||||
"environmental_attribute_optional": "Atrybut środowiskowy (opcjonalne)",
|
||||
"nfc_tag_id": "ID tagu NFC",
|
||||
"nfc_linked": "Tag NFC powiązany",
|
||||
"nfc_link_hint": "Kliknij, aby powiązać tag NFC",
|
||||
"responsible_user": "Odpowiedzialny użytkownik",
|
||||
"shared_with": "Shared with (rotation)",
|
||||
"shared_with_help": "Pick multiple people to share this task; the responsible person rotates on each completion.",
|
||||
"rotation_strategy": "Rotation",
|
||||
"rotation_none": "No rotation",
|
||||
"rotation_round_robin": "Round-robin",
|
||||
"rotation_least_completed": "Least completed",
|
||||
"rotation_random": "Random",
|
||||
"no_user_assigned": "(Brak przypisanego użytkownika)",
|
||||
"all_users": "Wszyscy użytkownicy",
|
||||
"my_tasks": "Moje zadania",
|
||||
"tab_calendar": "Kalendarz",
|
||||
"cal_no_events": "Brak konserwacji",
|
||||
"cal_window_7": "7 dni",
|
||||
"cal_window_14": "14 dni",
|
||||
"cal_window_30": "30 dni",
|
||||
"cal_window_365": "1 rok",
|
||||
"cal_every_n_days": "co {n} dni",
|
||||
"cal_source_time": "Czas",
|
||||
"cal_source_time_adaptive": "Czas (adaptacyjny)",
|
||||
"cal_source_sensor": "Czujnik",
|
||||
"cal_predicted": "przewidywane",
|
||||
"cal_confidence_high": "wysoka pewność",
|
||||
"cal_confidence_medium": "średnia pewność",
|
||||
"cal_confidence_low": "niska pewność",
|
||||
"budget_monthly": "Budżet miesięczny",
|
||||
"budget_yearly": "Budżet roczny",
|
||||
"groups": "Grupy",
|
||||
"new_group": "Nowa grupa",
|
||||
"edit_group": "Edytuj grupę",
|
||||
"no_groups": "Jeszcze brak grup",
|
||||
"delete_group": "Usuń grupę",
|
||||
"delete_group_confirm": "Usunąć grupę '{name}'?",
|
||||
"group_select_tasks": "Wybierz zadania",
|
||||
"group_name_required": "Nazwa jest wymagana",
|
||||
"description_optional": "Opis (opcjonalny)",
|
||||
"selected": "Wybrane",
|
||||
"loading_chart": "Ładowanie danych wykresu...",
|
||||
"hide_outliers": "Ukryj wartości odstające (błędy czujnika)",
|
||||
"was_maintenance_needed": "Czy ta konserwacja była potrzebna?",
|
||||
"feedback_needed": "Potrzebna",
|
||||
"feedback_not_needed": "Niepotrzebna",
|
||||
"feedback_not_sure": "Nie jestem pewien",
|
||||
"suggested_interval": "Sugerowany interwał",
|
||||
"apply_suggestion": "Zastosuj",
|
||||
"reanalyze": "Analizuj ponownie",
|
||||
"reanalyze_result": "Nowa analiza",
|
||||
"reanalyze_insufficient_data": "Za mało danych do wygenerowania rekomendacji",
|
||||
"data_points": "punkty danych",
|
||||
"dismiss_suggestion": "Odrzuć",
|
||||
"confidence_low": "Niska",
|
||||
"confidence_medium": "Średnia",
|
||||
"confidence_high": "Wysoka",
|
||||
"recommended": "rekomendowane",
|
||||
"seasonal_awareness": "Świadomość sezonowa",
|
||||
"edit_seasonal_overrides": "Edytuj czynniki sezonowe",
|
||||
"seasonal_overrides_title": "Czynniki sezonowe (nadpisanie)",
|
||||
"seasonal_overrides_hint": "Czynnik na miesiąc (0.1–5.0). Puste = uczone automatycznie.",
|
||||
"seasonal_override_invalid": "Nieprawidłowa wartość",
|
||||
"seasonal_override_range": "Czynnik musi być między 0.1 a 5.0",
|
||||
"clear_all": "Wyczyść wszystko",
|
||||
"seasonal_chart_title": "Czynniki sezonowe",
|
||||
"seasonal_learned": "Wyuczone",
|
||||
"seasonal_manual": "Ręczne",
|
||||
"month_jan": "Sty",
|
||||
"month_feb": "Lut",
|
||||
"month_mar": "Mar",
|
||||
"month_apr": "Kwi",
|
||||
"month_may": "Maj",
|
||||
"month_jun": "Cze",
|
||||
"month_jul": "Lip",
|
||||
"month_aug": "Sie",
|
||||
"month_sep": "Wrz",
|
||||
"month_oct": "Paź",
|
||||
"month_nov": "Lis",
|
||||
"month_dec": "Gru",
|
||||
"sensor_prediction": "Predykcja czujnika",
|
||||
"degradation_trend": "Trend",
|
||||
"trend_rising": "Rosnący",
|
||||
"trend_falling": "Malejący",
|
||||
"trend_stable": "Stabilny",
|
||||
"trend_insufficient_data": "Niewystarczające dane",
|
||||
"days_until_threshold": "Dni do progu",
|
||||
"threshold_exceeded": "Próg przekroczony",
|
||||
"environmental_adjustment": "Czynnik środowiskowy",
|
||||
"sensor_prediction_urgency": "Czujnik przewiduje próg za ~{days} dni",
|
||||
"day_short": "d",
|
||||
"weibull_reliability_curve": "Krzywa niezawodności",
|
||||
"weibull_failure_probability": "Prawdopodobieństwo awarii",
|
||||
"weibull_r_squared": "Dopasowanie R²",
|
||||
"beta_early_failures": "Wczesne awarie",
|
||||
"beta_random_failures": "Losowe awarie",
|
||||
"beta_wear_out": "Zużycie",
|
||||
"beta_highly_predictable": "Wysoce przewidywalne",
|
||||
"confidence_interval": "Przedział ufności",
|
||||
"confidence_conservative": "Konserwatywny",
|
||||
"confidence_aggressive": "Optymistyczny",
|
||||
"current_interval_marker": "Bieżący interwał",
|
||||
"recommended_marker": "Rekomendowany",
|
||||
"characteristic_life": "Charakterystyczna żywotność",
|
||||
"chart_mini_sparkline": "Wykres trendu",
|
||||
"chart_history": "Historia kosztów i czasu trwania",
|
||||
"chart_seasonal": "Czynniki sezonowe, 12 miesięcy",
|
||||
"chart_weibull": "Krzywa niezawodności Weibulla",
|
||||
"chart_sparkline": "Wykres wartości wyzwalacza czujnika",
|
||||
"days_progress": "Postęp dni",
|
||||
"qr_code": "Kod QR",
|
||||
"qr_generating": "Generowanie kodu QR…",
|
||||
"qr_error": "Nie udało się wygenerować kodu QR.",
|
||||
"qr_error_no_url": "Brak skonfigurowanego URL HA. Ustaw zewnętrzny lub wewnętrzny URL w Ustawienia → System → Sieć.",
|
||||
"save_error": "Nie udało się zapisać. Spróbuj ponownie.",
|
||||
"qr_print": "Drukuj",
|
||||
"qr_download": "Pobierz SVG",
|
||||
"qr_action": "Akcja przy skanowaniu",
|
||||
"qr_action_view": "Wyświetl informacje o konserwacji",
|
||||
"qr_action_complete": "Oznacz konserwację jako wykonaną",
|
||||
"qr_url_mode": "Typ linku",
|
||||
"qr_mode_companion": "Companion App",
|
||||
"qr_mode_local": "Lokalny (mDNS)",
|
||||
"qr_mode_server": "URL serwera",
|
||||
"overview": "Przegląd",
|
||||
"analysis": "Analiza",
|
||||
"recent_activities": "Ostatnie aktywności",
|
||||
"search_notes": "Szukaj w notatkach",
|
||||
"avg_cost": "Śr. koszt",
|
||||
"no_advanced_features": "Brak włączonych funkcji zaawansowanych",
|
||||
"no_advanced_features_hint": "Włącz „Adaptacyjne interwały” lub „Wzorce sezonowe” w ustawieniach integracji, aby zobaczyć tutaj dane analityczne.",
|
||||
"analysis_not_enough_data": "Jeszcze za mało danych do analizy.",
|
||||
"analysis_not_enough_data_hint": "Analiza Weibulla wymaga co najmniej 5 wykonanych konserwacji; wzorce sezonowe stają się widoczne po 6+ punktach danych na miesiąc.",
|
||||
"analysis_manual_task_hint": "Zadania ręczne bez interwału nie generują danych analitycznych.",
|
||||
"completions": "wykonania",
|
||||
"current": "Bieżące",
|
||||
"shorter": "Krótsze",
|
||||
"longer": "Dłuższe",
|
||||
"normal": "Normalne",
|
||||
"disabled": "Wyłączone",
|
||||
"compound_logic": "Logika złożona",
|
||||
"compound": "Złożony (wiele warunków)",
|
||||
"compound_logic_and": "I — wszystkie warunki muszą zadziałać",
|
||||
"compound_logic_or": "LUB — wystarczy dowolny warunek",
|
||||
"compound_help": "Połącz kilka warunków czujników w jeden wyzwalacz.",
|
||||
"compound_no_conditions": "Brak warunków — dodaj co najmniej jeden.",
|
||||
"compound_add_condition": "Dodaj warunek",
|
||||
"compound_condition": "Warunek",
|
||||
"compound_remove_condition": "Usuń warunek",
|
||||
"card_title": "Tytuł",
|
||||
"card_show_header": "Pokaż nagłówek ze statystykami",
|
||||
"card_show_actions": "Pokaż przyciski akcji",
|
||||
"card_compact": "Tryb kompaktowy",
|
||||
"card_max_items": "Maks. elementów (0 = wszystkie)",
|
||||
"card_filter_status": "Filtruj wg statusu",
|
||||
"card_filter_status_help": "Puste = pokaż wszystkie statusy.",
|
||||
"card_filter_objects": "Filtruj wg obiektów",
|
||||
"card_filter_objects_help": "Puste = pokaż wszystkie obiekty.",
|
||||
"card_filter_entities": "Filtruj wg encji (entity_ids)",
|
||||
"card_filter_entities_help": "Wybierz encje sensor / binary_sensor z tej integracji. Puste = wszystkie.",
|
||||
"card_loading_objects": "Ładowanie obiektów…",
|
||||
"card_load_error": "Nie udało się załadować obiektów — sprawdź połączenie WebSocket.",
|
||||
"card_no_tasks_title": "Brak zadań konserwacyjnych",
|
||||
"card_no_tasks_cta": "→ Utwórz w panelu Maintenance",
|
||||
"no_objects": "Brak obiektów.",
|
||||
"action_error": "Akcja nie powiodła się. Spróbuj ponownie.",
|
||||
"area_id_optional": "Obszar (opcjonalny)",
|
||||
"installation_date_optional": "Data instalacji (opcjonalna)",
|
||||
"warranty_expiry_optional": "Wygaśnięcie gwarancji (opcjonalnie)",
|
||||
"warranty": "Gwarancja",
|
||||
"warranty_valid_until": "ważna do {date}",
|
||||
"warranty_expires_in": "wygasa za {days} dni",
|
||||
"warranty_expired": "wygasła",
|
||||
"cal_past_windows": "Minione okna",
|
||||
"cal_forward_windows": "Przyszłe okna",
|
||||
"history_edit_title": "Edytuj wpis historii",
|
||||
"history_edit_timestamp": "Znacznik czasu",
|
||||
"manufacturer": "Producent",
|
||||
"model": "Model",
|
||||
"area": "Obszar",
|
||||
"actions": "Akcje",
|
||||
"view_mode_label": "Widok",
|
||||
"view_cards": "Widok kart",
|
||||
"view_table": "Widok tabeli",
|
||||
"objects_table_columns_label": "Kolumny tabeli obiektów",
|
||||
"objects_table_columns_hint": "Wybierz, które kolumny mają być widoczne w widoku tabeli obiektów.",
|
||||
"custom_icon_optional": "Ikona (opcjonalna, np. mdi:wrench)",
|
||||
"task_enabled": "Zadanie włączone",
|
||||
"skip_reason_prompt": "Pominąć to zadanie?",
|
||||
"reason_optional": "Powód (opcjonalny)",
|
||||
"reset_date_prompt": "Oznaczyć zadanie jako wykonane?",
|
||||
"reset_date_optional": "Data ostatniego wykonania (opcjonalna, domyślnie dzisiaj)",
|
||||
"notes_label": "Notatki",
|
||||
"documentation_label": "Dokumentacja",
|
||||
"no_nfc_tag": "— Brak tagu —",
|
||||
"dashboard": "Pulpit",
|
||||
"tab_today": "Dziś",
|
||||
"palette_placeholder": "Szukaj obiektów i zadań…",
|
||||
"palette_no_results": "Brak wyników",
|
||||
"palette_hint": "↑↓ nawigacja · Enter otwórz · Esc zamknij",
|
||||
"today_all_caught_up": "Wszystko zrobione! Nic w tym tygodniu.",
|
||||
"today_overdue": "Zaległe",
|
||||
"today_due_today": "Na dziś",
|
||||
"today_this_week": "W tym tygodniu",
|
||||
"settings": "Ustawienia",
|
||||
"settings_features": "Funkcje zaawansowane",
|
||||
"settings_features_desc": "Włącz lub wyłącz funkcje zaawansowane. Wyłączenie ukrywa je z UI, ale nie usuwa danych.",
|
||||
"feat_adaptive": "Harmonogram adaptacyjny",
|
||||
"feat_adaptive_desc": "Ucz się optymalnych interwałów z historii konserwacji",
|
||||
"feat_predictions": "Predykcje czujników",
|
||||
"feat_predictions_desc": "Przewiduj daty wyzwolenia z degradacji czujnika",
|
||||
"feat_seasonal": "Korekty sezonowe",
|
||||
"feat_seasonal_desc": "Dostosuj interwały do wzorców sezonowych",
|
||||
"feat_environmental": "Korelacja środowiskowa",
|
||||
"feat_environmental_desc": "Koreluj interwały z temperaturą/wilgotnością",
|
||||
"feat_budget": "Śledzenie budżetu",
|
||||
"feat_budget_desc": "Śledź miesięczne i roczne wydatki na konserwację",
|
||||
"feat_groups": "Grupy zadań",
|
||||
"feat_groups_desc": "Organizuj zadania w grupy logiczne",
|
||||
"feat_checklists": "Listy kontrolne",
|
||||
"feat_checklists_desc": "Wieloetapowe procedury wykonania zadania",
|
||||
"settings_general": "Ogólne",
|
||||
"settings_default_warning": "Domyślne dni ostrzeżenia",
|
||||
"settings_panel_enabled": "Panel boczny",
|
||||
"settings_panel_title": "Tytuł panelu bocznego",
|
||||
"settings_notifications": "Powiadomienia",
|
||||
"settings_notify_service": "Usługa powiadomień",
|
||||
"test_notification": "Powiadomienie testowe",
|
||||
"send_test": "Wyślij test",
|
||||
"testing": "Wysyłanie…",
|
||||
"test_notification_success": "Powiadomienie testowe wysłane",
|
||||
"test_notification_failed": "Powiadomienie testowe nie powiodło się",
|
||||
"settings_notify_due_soon": "Powiadom gdy wkrótce",
|
||||
"settings_notify_overdue": "Powiadom gdy zaległe",
|
||||
"settings_notify_triggered": "Powiadom gdy wyzwolone",
|
||||
"settings_interval_hours": "Interwał powtarzania (godziny, 0 = raz)",
|
||||
"settings_quiet_hours": "Godziny ciszy",
|
||||
"settings_quiet_start": "Początek",
|
||||
"settings_quiet_end": "Koniec",
|
||||
"settings_max_per_day": "Maks. powiadomień dziennie (0 = bez limitu)",
|
||||
"settings_bundling": "Grupowanie powiadomień",
|
||||
"settings_bundle_threshold": "Próg grupowania",
|
||||
"settings_reminder_leads": "Extra reminders (days before due)",
|
||||
"settings_reminder_leads_hint": "Comma-separated lead times, e.g. 14, 3, 0 — one extra reminder fires on each matching day. Empty = off.",
|
||||
"settings_actions": "Mobilne przyciski akcji",
|
||||
"settings_action_complete": "Pokaż przycisk 'Wykonaj'",
|
||||
"settings_action_skip": "Pokaż przycisk 'Pomiń'",
|
||||
"settings_action_snooze": "Pokaż przycisk 'Drzemka'",
|
||||
"settings_weekly_digest": "Cotygodniowe podsumowanie",
|
||||
"settings_weekly_digest_hint": "Jedno powiadomienie podsumowujące w poniedziałek rano, gdy są zadania.",
|
||||
"settings_warranty_reminder": "Warranty expiry reminder",
|
||||
"settings_warranty_reminder_days": "Days before expiry",
|
||||
"settings_warranty_reminder_hint": "Notify once when an object's warranty is this many days from expiring.",
|
||||
"settings_snooze_hours": "Czas drzemki (godziny)",
|
||||
"settings_budget": "Budżet",
|
||||
"settings_currency": "Waluta",
|
||||
"settings_budget_monthly": "Budżet miesięczny",
|
||||
"settings_budget_yearly": "Budżet roczny",
|
||||
"settings_budget_alerts": "Alerty budżetowe",
|
||||
"settings_budget_threshold": "Próg alertu (%)",
|
||||
"settings_import_export": "Import / Eksport",
|
||||
"settings_export_json": "Eksportuj JSON",
|
||||
"settings_export_yaml": "Eksportuj YAML",
|
||||
"settings_export_csv": "Eksportuj CSV",
|
||||
"settings_import_csv": "Importuj CSV",
|
||||
"settings_import_placeholder": "Wklej tutaj zawartość JSON lub CSV…",
|
||||
"settings_import_btn": "Importuj",
|
||||
"settings_import_success": "{count} obiektów zaimportowanych pomyślnie.",
|
||||
"settings_export_success": "Eksport pobrany.",
|
||||
"settings_saved": "Ustawienie zapisane.",
|
||||
"settings_include_history": "Dołącz historię",
|
||||
"sort_alphabetical": "Alfabetycznie",
|
||||
"sort_due_soonest": "Najbliższy termin",
|
||||
"sort_task_count": "Liczba zadań",
|
||||
"sort_area": "Obszar",
|
||||
"sort_assigned_user": "Przypisany użytkownik",
|
||||
"sort_group": "Grupa",
|
||||
"groupby_none": "Bez grupowania",
|
||||
"groupby_area": "Wg obszaru",
|
||||
"groupby_group": "Wg grupy",
|
||||
"groupby_user": "Wg użytkownika",
|
||||
"filter_label": "Filtr",
|
||||
"user_label": "Użytkownik",
|
||||
"sort_label": "Sortowanie",
|
||||
"group_by_label": "Grupuj wg",
|
||||
"state_value_help": "Użyj wartości stanu HA (zwykle małymi literami, np. \"on\"/\"off\"). Wielkość liter jest normalizowana przy zapisie.",
|
||||
"target_changes_help": "Liczba pasujących przejść przed wyzwoleniem (domyślnie: 1).",
|
||||
"qr_print_title": "Drukuj kody QR",
|
||||
"qr_print_desc": "Wygeneruj stronę do druku z kodami QR do wycięcia i naklejenia na sprzęcie.",
|
||||
"qr_print_load": "Załaduj obiekty",
|
||||
"qr_print_filter": "Filtr",
|
||||
"qr_print_objects": "Obiekty",
|
||||
"qr_print_actions": "Akcje",
|
||||
"qr_print_url_mode": "Typ linku",
|
||||
"qr_print_estimate": "Szacowane kody QR",
|
||||
"qr_print_over_limit": "limit 200, zawęź filtr",
|
||||
"qr_print_generate": "Generuj kody QR",
|
||||
"qr_print_generating": "Generowanie…",
|
||||
"qr_print_ready": "Kody QR gotowe",
|
||||
"qr_print_print_button": "Drukuj",
|
||||
"qr_print_empty": "Nic do wygenerowania",
|
||||
"qr_action_skip": "Pomiń",
|
||||
"vacation_title": "Tryb urlopowy",
|
||||
"vacation_active": "aktywny",
|
||||
"vacation_ended": "zakończony",
|
||||
"vacation_desc": "Zaplanuj urlop: powiadomienia są wstrzymane podczas okresu plus dni bufora. Możesz dodać wyjątki dla wybranych zadań.",
|
||||
"vacation_enable": "Włącz tryb urlopowy",
|
||||
"vacation_start": "Początek",
|
||||
"vacation_end": "Koniec",
|
||||
"vacation_buffer": "Bufor (dni)",
|
||||
"vacation_exempt_title": "Powiadamiaj mimo urlopu",
|
||||
"vacation_exempt_desc": "Wybierz zadania, które mają powiadamiać także w czasie urlopu (np. krytyczna chemia basenu).",
|
||||
"vacation_load_tasks": "Wczytaj zadania",
|
||||
"vacation_preview_btn": "Pokaż podgląd",
|
||||
"vacation_preview_affected": "zadań dotyczy",
|
||||
"vacation_event_due_soon": "wkrótce będzie wymagane",
|
||||
"vacation_event_overdue": "stanie się zaległe",
|
||||
"vacation_event_triggered_est": "możliwe wyzwolenie czujnika",
|
||||
"vacation_sensor_based": "(czujnikowe)",
|
||||
"vacation_action_notify": "Powiadamiaj mimo to",
|
||||
"vacation_action_unsilence": "Ponownie wycisz",
|
||||
"vacation_marked_complete": "Oznaczono jako wykonane",
|
||||
"vacation_marked_skip": "Pominięto",
|
||||
"vacation_end_now": "Zakończ urlop teraz",
|
||||
"unassigned": "Nieprzypisane",
|
||||
"no_area": "Brak obszaru",
|
||||
"has_overdue": "Ma zaległe zadania",
|
||||
"object": "Obiekt",
|
||||
"settings_panel_access": "Dostęp do panelu",
|
||||
"settings_panel_access_desc": "Administratorzy zawsze mają pełny dostęp. Aby delegować tworzenie, edytowanie i usuwanie wybranym użytkownikom bez uprawnień administratora, włącz tę opcję i zaznacz ich poniżej — pozostali widzą tylko Wykonaj i Pomiń.",
|
||||
"settings_operator_write": "Zezwól wybranym użytkownikom na tworzenie, edycję i usuwanie",
|
||||
"settings_operator_write_desc": "Wyłączone: tylko administratorzy mogą zmieniać treść. Włączone: wybrani użytkownicy poniżej również mają pełny dostęp.",
|
||||
"no_non_admin_users": "Nie znaleziono użytkowników nie-admin. Dodaj ich w Ustawienia → Osoby.",
|
||||
"owner_label": "Właściciel",
|
||||
"feat_completion_actions": "Akcje po zakończeniu",
|
||||
"feat_completion_actions_desc": "Akcja HA per zadanie po zakończeniu + QR szybkiego zakończenia z predefiniowanymi wartościami.",
|
||||
"on_complete_action_title": "Po zakończeniu: wywołaj akcję HA (opcjonalnie)",
|
||||
"on_complete_action_desc": "Wywołuje usługę HA po zakończeniu zadania — np. zresetuj licznik urządzenia.",
|
||||
"on_complete_action_service": "Usługa",
|
||||
"on_complete_action_target": "Encja docelowa",
|
||||
"on_complete_action_data": "Dane (JSON, opcjonalnie)",
|
||||
"on_complete_action_test": "Testuj akcję",
|
||||
"on_complete_action_test_success": "Sukces",
|
||||
"on_complete_action_test_failed": "Błąd",
|
||||
"quick_complete_defaults_title": "Wartości domyślne szybkiego zakończenia (dla skanów QR, opcjonalnie)",
|
||||
"quick_complete_defaults_desc": "Predefiniowane wartości dla QR szybkiego zakończenia. Bez nich QR otwiera okno dialogowe.",
|
||||
"quick_complete_defaults_notes": "Notatki",
|
||||
"quick_complete_defaults_cost": "Koszt",
|
||||
"quick_complete_defaults_duration": "Czas trwania (minuty)",
|
||||
"quick_complete_defaults_feedback_none": "Brak opinii",
|
||||
"quick_complete_defaults_feedback_needed": "Było potrzebne",
|
||||
"quick_complete_defaults_feedback_not_needed": "Nie było potrzebne",
|
||||
"quick_complete_success": "Szybko oznaczono jako wykonane",
|
||||
"trigger_replaced": "Wyzwalacz zastąpiony",
|
||||
"add": "Dodaj",
|
||||
"show_stats": "Pokaż statystyki + wykresy",
|
||||
"hide_stats": "Ukryj statystyki",
|
||||
"adaptive_no_data": "Za mało historii ukończeń do analizy adaptacyjnej. Wykonaj to zadanie jeszcze kilka razy, aby odblokować propozycje interwału i wykresy niezawodności.",
|
||||
"suggestion_applied": "Zastosowano sugerowany interwał",
|
||||
"vacation_mode": "Tryb urlopowy",
|
||||
"vacation_status_active": "Aktywny teraz",
|
||||
"vacation_status_scheduled": "Zaplanowany",
|
||||
"vacation_status_inactive": "Nieaktywny",
|
||||
"vacation_end_now_confirm": "Zakończyć urlop natychmiast?",
|
||||
"vacation_exempt_count": "wykluczone",
|
||||
"vacation_advanced": "Zaawansowane…",
|
||||
"vacation_open_panel": "Otwórz w panelu",
|
||||
"enable": "Włącz",
|
||||
"saved": "Zapisano",
|
||||
"budget_monthly_set": "Ustaw miesięczny",
|
||||
"budget_yearly_set": "Ustaw roczny",
|
||||
"budget_advanced": "Waluta, alerty…",
|
||||
"budget_open_panel": "Otwórz w panelu",
|
||||
"groups_empty": "Brak grup.",
|
||||
"group_new_placeholder": "Dodaj grupę…",
|
||||
"group_delete_confirm": "Usunąć grupę „{name}”?",
|
||||
"groups_manage_tasks": "Zarządzaj przypisaniami zadań…",
|
||||
"groups_open_panel": "Otwórz w panelu",
|
||||
"on_complete_action_target_hint": "Uwaga: domena encji musi pasować do usługi — np. 'button.press' działa tylko na button.*, 'counter.increment' tylko na counter.*, 'input_button.press' tylko na input_button.* itd. W przypadku niezgodności akcja po cichu się nie wykona (HA zapisze w dzienniku 'Referenced entities ... missing or not currently available').",
|
||||
"show_all_objects": "Pokaż wszystkie obiekty",
|
||||
"show_all_tasks": "Wyczyść filtr — pokaż wszystkie zadania",
|
||||
"filter_to_overdue": "Filtruj listę tylko do zaległych",
|
||||
"filter_to_due_soon": "Filtruj listę tylko do zbliżających się terminów",
|
||||
"filter_to_triggered": "Filtruj listę tylko do wyzwolonych",
|
||||
"open_task": "Otwórz zadanie",
|
||||
"show_details": "Pokaż historię + statystyki",
|
||||
"hide_details": "Ukryj szczegóły",
|
||||
"history_empty": "Brak historii.",
|
||||
"history_edit_button": "Edytuj wpis",
|
||||
"total_cost": "Koszt całkowity",
|
||||
"times_performed": "Wykonano",
|
||||
"older_entries": "starsze",
|
||||
"open_in_panel": "Otwórz w panelu Konserwacja",
|
||||
"skip_reason": "Powód pominięcia (opcjonalnie)",
|
||||
"reset_to_date": "Zresetuj ostatnie wykonanie na",
|
||||
"delete_task_confirm": "Usunąć to zadanie i jego historię?",
|
||||
"delete_object_confirm": "Usunąć ten obiekt i wszystkie jego zadania?",
|
||||
"loading": "Ładowanie…",
|
||||
"archive": "Archiwizuj",
|
||||
"undo": "Cofnij",
|
||||
"task_archived": "Zadanie zarchiwizowane",
|
||||
"object_archived": "Obiekt zarchiwizowany",
|
||||
"unarchive": "Przywróć z archiwum",
|
||||
"archived": "Zarchiwizowane",
|
||||
"show_archived": "Pokaż zarchiwizowane",
|
||||
"hide_archived": "Ukryj zarchiwizowane",
|
||||
"confirm_archive_object": "Zarchiwizować ten obiekt i jego zadania? Historia zostanie zachowana i można ją później przywrócić.",
|
||||
"settings_archive": "Archiwum i przechowywanie",
|
||||
"settings_archive_desc": "Odkładaj ukończone zadania jednorazowe bez usuwania. Zarchiwizowane elementy są ukryte i nieaktywne, ale zachowują historię i koszty.",
|
||||
"settings_archive_oneoff_days": "Automatycznie archiwizuj ukończone zadania jednorazowe po (dni, 0 = wył.)",
|
||||
"settings_delete_archived_oneoff_days": "Automatycznie usuwaj zarchiwizowane zadania jednorazowe po (dni, 0 = nigdy)",
|
||||
"archive_object": "Archiwizuj obiekt",
|
||||
"unarchive_object": "Przywróć obiekt z archiwum",
|
||||
"documents": "Dokumenty",
|
||||
"documents_empty": "Brak dokumentów.",
|
||||
"doc_upload": "Prześlij plik",
|
||||
"doc_uploading": "Przesyłanie…",
|
||||
"doc_add_link": "Dodaj link",
|
||||
"doc_link_url": "URL (https://…)",
|
||||
"doc_link_title": "Tytuł (opcjonalnie)",
|
||||
"doc_open": "Otwórz",
|
||||
"doc_delete_confirm": "Usunąć „{name}”?",
|
||||
"doc_too_large": "Plik jest za duży (maks. 25 MB).",
|
||||
"doc_upload_failed": "Przesyłanie nie powiodło się.",
|
||||
"completion_photo_optional": "Completion photo (optional)",
|
||||
"add_photo": "Add photo",
|
||||
"uploading": "Uploading…",
|
||||
"remove": "Remove",
|
||||
"doc_deduped": "Już zapisany gdzie indziej — współdzielony, bez dodatkowego miejsca.",
|
||||
"doc_dup_in_object": "Ten plik jest już dołączony do tego obiektu.",
|
||||
"doc_link_invalid": "Dozwolone są tylko linki http/https.",
|
||||
"doc_cat_manual": "Instrukcja",
|
||||
"doc_cat_warranty": "Gwarancja",
|
||||
"doc_cat_invoice": "Faktura",
|
||||
"doc_cat_spare_parts": "Części zamienne",
|
||||
"doc_cat_photo": "Zdjęcie",
|
||||
"doc_cat_other": "Inne",
|
||||
"doc_link_badge": "Link",
|
||||
"doc_storage_title": "Magazyn dokumentów",
|
||||
"doc_storage_saved": "Zaoszczędzone przez deduplikację",
|
||||
"doc_storage_refresh": "Odśwież",
|
||||
"doc_download": "Pobierz",
|
||||
"doc_close": "Zamknij",
|
||||
"doc_camera": "Zrób zdjęcie",
|
||||
"doc_drop_hint": "Upuść pliki tutaj",
|
||||
"doc_task_none": "Brak dokumentów powiązanych z tym zadaniem.",
|
||||
"doc_link_existing": "Powiąż dokument…",
|
||||
"doc_attach": "Powiąż",
|
||||
"doc_unlink": "Odłącz",
|
||||
"doc_page": "Strona",
|
||||
"chart_range_7d": "7d",
|
||||
"chart_range_30d": "30d",
|
||||
"chart_range_90d": "90d",
|
||||
"chart_range_1y": "1r",
|
||||
"chart_since_service": "od ostatniej konserwacji",
|
||||
"chart_no_stats": "Brak statystyk długoterminowych dla tej encji — wyświetlane są tylko wartości z wpisów konserwacji",
|
||||
"auto_complete_on_recovery": "Zakończ automatycznie, gdy czujnik wróci do normy",
|
||||
"auto_complete_on_recovery_help": "Rejestruje wykonanie (ustawia ostatnie wykonanie), gdy wyzwalacz sam się rozwiąże — np. uzupełniono sól, wymieniono filtr.",
|
||||
"doc_search": "Szukaj dokumentów…",
|
||||
"doc_search_none": "Brak pasujących dokumentów",
|
||||
"link_device_optional": "Połącz z istniejącym urządzeniem (opcjonalnie)",
|
||||
"parent_object_optional": "Obiekt nadrzędny (opcjonalnie)",
|
||||
"parent_none": "(Brak nadrzędnego)",
|
||||
"paused": "Wstrzymane",
|
||||
"pause_object": "Wstrzymaj",
|
||||
"resume_object": "Wznów",
|
||||
"pause_until_prompt": "Zamroź harmonogramy tego obiektu — nic nie staje się wymagane i nic nie powiadamia do czasu wznowienia. Opcjonalnie ustaw datę automatycznego wznowienia.",
|
||||
"pause_until_label": "Wznów dnia (opcjonalnie)",
|
||||
"object_paused": "Obiekt wstrzymany",
|
||||
"object_resumed": "Obiekt wznowiony — harmonogramy uruchomione od nowa",
|
||||
"object_paused_badge": "Wstrzymane",
|
||||
"paused_until_label": "do",
|
||||
"replace_object": "Zastąp…",
|
||||
"replace_object_prompt": "Wycofaj ten obiekt i utwórz następcę. Historia i koszty pozostają zarchiwizowane przy starym; zadania i dokumenty przechodzą do nowego, liczniki startują od zera.",
|
||||
"replace_name_label": "Nazwa następcy",
|
||||
"object_replaced": "Obiekt zastąpiony — utworzono następcę",
|
||||
"reading_unit_label": "Jednostka odczytu (np. kWh, m³)",
|
||||
"reading_unit_help": "Wyświetlana obok zarejestrowanej wartości przy ukończeniu tego zadania.",
|
||||
"reading_value_label": "Wartość odczytu",
|
||||
"reading_label": "Odczyt",
|
||||
"settings_templates_label": "Galeria szablonów",
|
||||
"settings_templates_hint": "Odznacz szablony, których nigdy nie użyjesz — znikną z wyboru „Z szablonu\" (panel i config flow). Nic więcej się nie zmienia; można je włączyć ponownie w każdej chwili.",
|
||||
"worksheet": "Karta pracy",
|
||||
"worksheet_scan_view": "Zeskanuj, aby otworzyć zadanie",
|
||||
"worksheet_scan_complete": "Zeskanuj, aby ukończyć",
|
||||
"worksheet_manual_excerpt": "Fragment instrukcji",
|
||||
"worksheet_pages": "strony",
|
||||
"worksheet_printed": "Wydrukowano",
|
||||
"worksheet_never": "Nigdy"
|
||||
}
|
||||
@@ -0,0 +1,696 @@
|
||||
{
|
||||
"maintenance": "Manutenção",
|
||||
"objects": "Objetos",
|
||||
"tasks": "Tarefas",
|
||||
"overdue": "Atrasada",
|
||||
"due_soon": "Próxima",
|
||||
"triggered": "Acionada",
|
||||
"ok": "OK",
|
||||
"all": "Todos",
|
||||
"new_object": "+ Novo objeto",
|
||||
"templates_from": "De modelo",
|
||||
"templates_title": "Começar a partir de um modelo",
|
||||
"templates_task_count": "{n} tarefas",
|
||||
"template_created": "Criado a partir de modelo",
|
||||
"onboard_hint": "Adicione o seu primeiro objeto para acompanhar a manutenção.",
|
||||
"edit": "Editar",
|
||||
"duplicate": "Duplicar",
|
||||
"task_duplicated": "Tarefa duplicada",
|
||||
"object_duplicated": "Objeto duplicado",
|
||||
"delete": "Eliminar",
|
||||
"add_task": "+ Tarefa",
|
||||
"complete": "Concluída",
|
||||
"completed": "Concluída",
|
||||
"skip": "Saltar",
|
||||
"skipped": "Saltada",
|
||||
"missed": "Missed",
|
||||
"reset": "Repor",
|
||||
"snooze": "Snooze",
|
||||
"snoozed": "Snoozed",
|
||||
"cancel": "Cancelar",
|
||||
"bulk_select": "Selecionar",
|
||||
"bulk_select_all": "Selecionar tudo",
|
||||
"bulk_n_selected": "{n} selecionados",
|
||||
"bulk_completed": "{n} tarefas concluídas",
|
||||
"bulk_archived": "{n} tarefas arquivadas",
|
||||
"completing": "A concluir…",
|
||||
"interval": "Intervalo",
|
||||
"warning": "Aviso",
|
||||
"last_performed": "Última execução",
|
||||
"next_due": "Próximo vencimento",
|
||||
"days_until_due": "Dias até vencimento",
|
||||
"avg_duration": "Ø Duração",
|
||||
"trigger": "Acionador",
|
||||
"trigger_type": "Tipo de acionador",
|
||||
"threshold_above": "Limite superior",
|
||||
"threshold_below": "Limite inferior",
|
||||
"threshold": "Limiar",
|
||||
"counter": "Contador",
|
||||
"state_change": "Mudança de estado",
|
||||
"runtime": "Tempo de funcionamento",
|
||||
"runtime_hours": "Duração alvo (horas)",
|
||||
"target_value": "Valor alvo",
|
||||
"baseline": "Linha de base",
|
||||
"target_changes": "Alterações alvo",
|
||||
"for_minutes": "Durante (minutos)",
|
||||
"time_based": "Temporal",
|
||||
"sensor_based": "Sensor",
|
||||
"manual": "Manual",
|
||||
"one_time": "Única vez",
|
||||
"weekdays": "Dias da semana",
|
||||
"nth_weekday": "N-ésimo dia da semana do mês",
|
||||
"day_of_month": "Dia do mês",
|
||||
"recurrence_on_days": "Repetir em",
|
||||
"recurrence_occurrence": "Ocorrência",
|
||||
"recurrence_weekday": "Dia da semana",
|
||||
"recurrence_day": "Dia do mês (1–31)",
|
||||
"recurrence_last_day": "Last day of the month",
|
||||
"recurrence_business_day": "Business days only (roll back from weekend)",
|
||||
"recurrence_offset": "Offset (days, ±)",
|
||||
"recurrence_offset_help": "Shift the date by ±N days, e.g. -2 = two days before.",
|
||||
"last_day_month": "Last day of month",
|
||||
"last_business_day_month": "Last business day",
|
||||
"ord_1": "1.º",
|
||||
"ord_2": "2.º",
|
||||
"ord_3": "3.º",
|
||||
"ord_4": "4.º",
|
||||
"ord_5": "5.º",
|
||||
"ord_last": "Último",
|
||||
"day_word": "Dia",
|
||||
"interval_value": "Intervalo",
|
||||
"interval_unit": "Unidade",
|
||||
"unit_days": "Dias",
|
||||
"unit_weeks": "Semanas",
|
||||
"unit_months": "Meses",
|
||||
"unit_years": "Anos",
|
||||
"due_date": "Data de vencimento",
|
||||
"cleaning": "Limpeza",
|
||||
"inspection": "Inspeção",
|
||||
"replacement": "Substituição",
|
||||
"calibration": "Calibração",
|
||||
"service": "Serviço",
|
||||
"reading": "Leitura",
|
||||
"custom": "Personalizado",
|
||||
"history": "Histórico",
|
||||
"cost": "Custo",
|
||||
"report_button": "Relatório",
|
||||
"report_title": "Relatório de manutenção",
|
||||
"report_generated": "Gerado",
|
||||
"report_times_done": "Feito",
|
||||
"report_total_cost": "Custo total",
|
||||
"report_every": "a cada {n} {unit}",
|
||||
"report_notes": "Notas",
|
||||
"report_col_type": "Tipo",
|
||||
"report_col_status": "Estado",
|
||||
"report_col_schedule": "Agenda",
|
||||
"duration": "Duração",
|
||||
"both": "Ambos",
|
||||
"trigger_val": "Valor do acionador",
|
||||
"complete_title": "Concluída: ",
|
||||
"checklist": "Lista de verificação",
|
||||
"checklist_steps_optional": "Passos da lista de verificação (opcional)",
|
||||
"checklist_placeholder": "Limpar filtro\nSubstituir vedação\nTestar pressão",
|
||||
"checklist_help": "Um passo por linha. Máx. 100 itens.",
|
||||
"err_too_long": "{field}: demasiado longo (máx. {n} caracteres)",
|
||||
"err_too_short": "{field}: demasiado curto (mín. {n} caracteres)",
|
||||
"err_value_too_high": "{field}: demasiado grande (máx. {n})",
|
||||
"err_value_too_low": "{field}: demasiado pequeno (mín. {n})",
|
||||
"err_required": "{field}: campo obrigatório",
|
||||
"err_wrong_type": "{field}: tipo incorreto (esperado: {type})",
|
||||
"err_invalid_choice": "{field}: valor não permitido",
|
||||
"err_invalid_value": "{field}: valor inválido",
|
||||
"feat_schedule_time": "Agendamento por hora",
|
||||
"feat_schedule_time_desc": "Tarefas vencem em um horário específico em vez de meia-noite.",
|
||||
"schedule_time_optional": "Vence às (opcional, HH:MM)",
|
||||
"schedule_time_help": "Vazio = meia-noite (padrão). Fuso horário HA.",
|
||||
"at_time": "às",
|
||||
"notes_optional": "Notas (opcional)",
|
||||
"cost_optional": "Custo (opcional)",
|
||||
"duration_minutes": "Duração em minutos (opcional)",
|
||||
"days": "dias",
|
||||
"day": "dia",
|
||||
"today": "Hoje",
|
||||
"d_overdue": "d em atraso",
|
||||
"no_tasks": "Sem tarefas de manutenção. Crie um objeto para começar.",
|
||||
"no_tasks_short": "Sem tarefas",
|
||||
"no_history": "Sem entradas no histórico.",
|
||||
"show_all": "Mostrar tudo",
|
||||
"cost_duration_chart": "Custos & Duração",
|
||||
"installed": "Instalado",
|
||||
"confirm_delete_object": "Eliminar este objeto e todas as suas tarefas?",
|
||||
"confirm_delete_task": "Eliminar esta tarefa?",
|
||||
"min": "Mín",
|
||||
"max": "Máx",
|
||||
"save": "Guardar",
|
||||
"saving": "A guardar…",
|
||||
"edit_task": "Editar tarefa",
|
||||
"new_task": "Nova tarefa de manutenção",
|
||||
"task_name": "Nome da tarefa",
|
||||
"maintenance_type": "Tipo de manutenção",
|
||||
"priority": "Prioridade",
|
||||
"labels": "Labels",
|
||||
"labels_placeholder": "e.g. safety, seasonal, tenant-visible",
|
||||
"labels_help": "Comma-separated tags for filtering and reporting.",
|
||||
"priority_low": "Baixa",
|
||||
"priority_normal": "Normal",
|
||||
"priority_high": "Alta",
|
||||
"schedule_type": "Tipo de agendamento",
|
||||
"interval_days": "Intervalo (dias)",
|
||||
"warning_days": "Dias de aviso",
|
||||
"earliest_completion_days": "Earliest completion (days before due)",
|
||||
"earliest_completion_days_help": "Leave empty to allow completing any time. 0 = only on/after the due date.",
|
||||
"last_performed_optional": "Última execução (opcional)",
|
||||
"interval_anchor": "Âncora do intervalo",
|
||||
"anchor_completion": "A partir da data de conclusão",
|
||||
"anchor_planned": "A partir da data planeada (sem desvio)",
|
||||
"edit_object": "Editar objeto",
|
||||
"name": "Nome",
|
||||
"manufacturer_optional": "Fabricante (opcional)",
|
||||
"model_optional": "Modelo (opcional)",
|
||||
"serial_number_optional": "Número de série (opcional)",
|
||||
"serial_number_label": "N/S",
|
||||
"documentation_url_label": "Manual",
|
||||
"object_notes_label": "Notas",
|
||||
"sort_due_date": "Vencimento",
|
||||
"sort_object": "Nome do objeto",
|
||||
"sort_type": "Tipo",
|
||||
"sort_task_name": "Nome da tarefa",
|
||||
"all_objects": "Todos os objetos",
|
||||
"tasks_lower": "tarefas",
|
||||
"no_tasks_yet": "Ainda sem tarefas",
|
||||
"add_first_task": "Adicionar primeira tarefa",
|
||||
"trigger_configuration": "Configuração do acionador",
|
||||
"entity_id": "ID da entidade",
|
||||
"comma_separated": "separados por vírgulas",
|
||||
"entity_logic": "Lógica da entidade",
|
||||
"entity_logic_any": "Qualquer entidade aciona",
|
||||
"entity_logic_all": "Todas as entidades devem acionar",
|
||||
"entities": "entidades",
|
||||
"attribute_optional": "Atributo (opcional, vazio = estado)",
|
||||
"use_entity_state": "Usar estado da entidade (sem atributo)",
|
||||
"trigger_above": "Acionar acima de",
|
||||
"trigger_below": "Acionar abaixo de",
|
||||
"for_at_least_minutes": "Durante pelo menos (minutos)",
|
||||
"safety_interval_days": "Intervalo de segurança (dias, opcional)",
|
||||
"safety_interval": "Intervalo de segurança (opcional)",
|
||||
"delta_mode": "Modo delta",
|
||||
"from_state_optional": "Do estado (opcional)",
|
||||
"to_state_optional": "Para o estado (opcional)",
|
||||
"documentation_url_optional": "URL de documentação (opcional)",
|
||||
"object_notes_optional": "Notas (opcional)",
|
||||
"nfc_tag_id_optional": "ID da etiqueta NFC (opcional)",
|
||||
"nfc_tags_empty_help": "Ainda nenhuma tag NFC registada no Home Assistant.",
|
||||
"nfc_tags_open_settings": "Abrir configurações de tags",
|
||||
"nfc_tags_refresh": "Atualizar",
|
||||
"environmental_entity_optional": "Sensor ambiental (opcional)",
|
||||
"environmental_entity_helper": "ex. sensor.temperatura_exterior — ajusta o intervalo segundo as condições ambientais",
|
||||
"environmental_attribute_optional": "Atributo ambiental (opcional)",
|
||||
"nfc_tag_id": "ID da etiqueta NFC",
|
||||
"nfc_linked": "Etiqueta NFC associada",
|
||||
"nfc_link_hint": "Clique para associar etiqueta NFC",
|
||||
"responsible_user": "Utilizador responsável",
|
||||
"shared_with": "Shared with (rotation)",
|
||||
"shared_with_help": "Pick multiple people to share this task; the responsible person rotates on each completion.",
|
||||
"rotation_strategy": "Rotation",
|
||||
"rotation_none": "No rotation",
|
||||
"rotation_round_robin": "Round-robin",
|
||||
"rotation_least_completed": "Least completed",
|
||||
"rotation_random": "Random",
|
||||
"no_user_assigned": "(Nenhum utilizador atribuído)",
|
||||
"all_users": "Todos os utilizadores",
|
||||
"my_tasks": "As minhas tarefas",
|
||||
"tab_calendar": "Calendário",
|
||||
"cal_no_events": "Sem manutenção",
|
||||
"cal_window_7": "7 dias",
|
||||
"cal_window_14": "14 dias",
|
||||
"cal_window_30": "30 dias",
|
||||
"cal_window_365": "1 ano",
|
||||
"cal_every_n_days": "a cada {n} dias",
|
||||
"cal_source_time": "Baseado em tempo",
|
||||
"cal_source_time_adaptive": "Baseado em tempo (adaptativo)",
|
||||
"cal_source_sensor": "Baseado em sensor",
|
||||
"cal_predicted": "previsto",
|
||||
"cal_confidence_high": "alta confiança",
|
||||
"cal_confidence_medium": "confiança média",
|
||||
"cal_confidence_low": "baixa confiança",
|
||||
"budget_monthly": "Orçamento mensal",
|
||||
"budget_yearly": "Orçamento anual",
|
||||
"groups": "Grupos",
|
||||
"new_group": "Novo grupo",
|
||||
"edit_group": "Editar grupo",
|
||||
"no_groups": "Ainda sem grupos",
|
||||
"delete_group": "Eliminar grupo",
|
||||
"delete_group_confirm": "Eliminar o grupo '{name}'?",
|
||||
"group_select_tasks": "Selecionar tarefas",
|
||||
"group_name_required": "Nome obrigatório",
|
||||
"description_optional": "Descrição (opcional)",
|
||||
"selected": "Selecionado",
|
||||
"loading_chart": "A carregar dados...",
|
||||
"hide_outliers": "Ocultar valores atípicos (erros do sensor)",
|
||||
"was_maintenance_needed": "Esta manutenção era necessária?",
|
||||
"feedback_needed": "Necessária",
|
||||
"feedback_not_needed": "Não necessária",
|
||||
"feedback_not_sure": "Não tenho a certeza",
|
||||
"suggested_interval": "Intervalo sugerido",
|
||||
"apply_suggestion": "Aplicar",
|
||||
"reanalyze": "Reanalisar",
|
||||
"reanalyze_result": "Nova análise",
|
||||
"reanalyze_insufficient_data": "Dados insuficientes para uma recomendação",
|
||||
"data_points": "pontos de dados",
|
||||
"dismiss_suggestion": "Descartar",
|
||||
"confidence_low": "Baixa",
|
||||
"confidence_medium": "Média",
|
||||
"confidence_high": "Alta",
|
||||
"recommended": "recomendado",
|
||||
"seasonal_awareness": "Consciência sazonal",
|
||||
"edit_seasonal_overrides": "Editar fatores sazonais",
|
||||
"seasonal_overrides_title": "Fatores sazonais (override)",
|
||||
"seasonal_overrides_hint": "Fator por mês (0.1–5.0). Vazio = aprendido automaticamente.",
|
||||
"seasonal_override_invalid": "Valor inválido",
|
||||
"seasonal_override_range": "O fator deve estar entre 0.1 e 5.0",
|
||||
"clear_all": "Limpar tudo",
|
||||
"seasonal_chart_title": "Fatores sazonais",
|
||||
"seasonal_learned": "Aprendido",
|
||||
"seasonal_manual": "Manual",
|
||||
"month_jan": "Jan",
|
||||
"month_feb": "Fev",
|
||||
"month_mar": "Mar",
|
||||
"month_apr": "Abr",
|
||||
"month_may": "Mai",
|
||||
"month_jun": "Jun",
|
||||
"month_jul": "Jul",
|
||||
"month_aug": "Ago",
|
||||
"month_sep": "Set",
|
||||
"month_oct": "Out",
|
||||
"month_nov": "Nov",
|
||||
"month_dec": "Dez",
|
||||
"sensor_prediction": "Previsão do sensor",
|
||||
"degradation_trend": "Tendência",
|
||||
"trend_rising": "A subir",
|
||||
"trend_falling": "A descer",
|
||||
"trend_stable": "Estável",
|
||||
"trend_insufficient_data": "Dados insuficientes",
|
||||
"days_until_threshold": "Dias até ao limiar",
|
||||
"threshold_exceeded": "Limiar ultrapassado",
|
||||
"environmental_adjustment": "Fator ambiental",
|
||||
"sensor_prediction_urgency": "O sensor prevê o limiar em ~{days} dias",
|
||||
"day_short": "dia",
|
||||
"weibull_reliability_curve": "Curva de fiabilidade",
|
||||
"weibull_failure_probability": "Probabilidade de falha",
|
||||
"weibull_r_squared": "Ajuste R²",
|
||||
"beta_early_failures": "Falhas precoces",
|
||||
"beta_random_failures": "Falhas aleatórias",
|
||||
"beta_wear_out": "Desgaste",
|
||||
"beta_highly_predictable": "Altamente previsível",
|
||||
"confidence_interval": "Intervalo de confiança",
|
||||
"confidence_conservative": "Conservador",
|
||||
"confidence_aggressive": "Otimista",
|
||||
"current_interval_marker": "Intervalo atual",
|
||||
"recommended_marker": "Recomendado",
|
||||
"characteristic_life": "Vida característica",
|
||||
"chart_mini_sparkline": "Sparkline de tendência",
|
||||
"chart_history": "Histórico de custos e duração",
|
||||
"chart_seasonal": "Fatores sazonais, 12 meses",
|
||||
"chart_weibull": "Curva de fiabilidade Weibull",
|
||||
"chart_sparkline": "Gráfico de valor do acionador",
|
||||
"days_progress": "Progresso em dias",
|
||||
"qr_code": "Código QR",
|
||||
"qr_generating": "A gerar código QR…",
|
||||
"qr_error": "Não foi possível gerar o código QR.",
|
||||
"qr_error_no_url": "Nenhum URL do HA configurado. Defina um URL externo ou interno em Definições → Sistema → Rede.",
|
||||
"save_error": "Erro ao guardar. Tente novamente.",
|
||||
"qr_print": "Imprimir",
|
||||
"qr_download": "Transferir SVG",
|
||||
"qr_action": "Ação ao digitalizar",
|
||||
"qr_action_view": "Ver informações de manutenção",
|
||||
"qr_action_complete": "Marcar manutenção como concluída",
|
||||
"qr_url_mode": "Tipo de ligação",
|
||||
"qr_mode_companion": "Companion App",
|
||||
"qr_mode_local": "Local (mDNS)",
|
||||
"qr_mode_server": "URL do servidor",
|
||||
"overview": "Visão geral",
|
||||
"analysis": "Análise",
|
||||
"recent_activities": "Atividades recentes",
|
||||
"search_notes": "Pesquisar notas",
|
||||
"avg_cost": "Ø Custo",
|
||||
"no_advanced_features": "Sem funções avançadas ativadas",
|
||||
"no_advanced_features_hint": "Ative “Intervalos Adaptativos” ou “Padrões Sazonais” nas definições da integração para ver dados de análise aqui.",
|
||||
"analysis_not_enough_data": "Ainda não há dados suficientes para a análise.",
|
||||
"analysis_not_enough_data_hint": "A análise Weibull requer pelo menos 5 manutenções concluídas; os padrões sazonais tornam-se visíveis após 6+ pontos de dados por mês.",
|
||||
"analysis_manual_task_hint": "Tarefas manuais sem intervalo não geram dados de análise.",
|
||||
"completions": "conclusões",
|
||||
"current": "Atual",
|
||||
"shorter": "Mais curto",
|
||||
"longer": "Mais longo",
|
||||
"normal": "Normal",
|
||||
"disabled": "Desativado",
|
||||
"compound_logic": "Lógica composta",
|
||||
"compound": "Composto (várias condições)",
|
||||
"compound_logic_and": "E — todas as condições devem disparar",
|
||||
"compound_logic_or": "OU — basta uma condição",
|
||||
"compound_help": "Combine várias condições de sensores num só disparador.",
|
||||
"compound_no_conditions": "Ainda sem condições — adicione pelo menos uma.",
|
||||
"compound_add_condition": "Adicionar condição",
|
||||
"compound_condition": "Condição",
|
||||
"compound_remove_condition": "Remover condição",
|
||||
"card_title": "Título",
|
||||
"card_show_header": "Mostrar cabeçalho com estatísticas",
|
||||
"card_show_actions": "Mostrar botões de ação",
|
||||
"card_compact": "Modo compacto",
|
||||
"card_max_items": "Máx. itens (0 = todos)",
|
||||
"card_filter_status": "Filtrar por estado",
|
||||
"card_filter_status_help": "Vazio = mostrar todos os estados.",
|
||||
"card_filter_objects": "Filtrar por objetos",
|
||||
"card_filter_objects_help": "Vazio = mostrar todos os objetos.",
|
||||
"card_filter_entities": "Filtrar por entidades (entity_ids)",
|
||||
"card_filter_entities_help": "Selecione entidades sensor / binary_sensor desta integração. Vazio = todas.",
|
||||
"card_loading_objects": "A carregar objetos…",
|
||||
"card_load_error": "Não foi possível carregar os objetos — verifique a ligação WebSocket.",
|
||||
"card_no_tasks_title": "Ainda sem tarefas de manutenção",
|
||||
"card_no_tasks_cta": "→ Crie uma no painel Manutenção",
|
||||
"no_objects": "Ainda sem objetos.",
|
||||
"action_error": "Ação falhada. Tente novamente.",
|
||||
"area_id_optional": "Área (opcional)",
|
||||
"installation_date_optional": "Data de instalação (opcional)",
|
||||
"warranty_expiry_optional": "Validade da garantia (opcional)",
|
||||
"warranty": "Garantia",
|
||||
"warranty_valid_until": "válida até {date}",
|
||||
"warranty_expires_in": "expira em {days} dias",
|
||||
"warranty_expired": "expirada",
|
||||
"cal_past_windows": "Janelas passadas",
|
||||
"cal_forward_windows": "Janelas futuras",
|
||||
"history_edit_title": "Editar entrada do histórico",
|
||||
"history_edit_timestamp": "Carimbo de data/hora",
|
||||
"manufacturer": "Fabricante",
|
||||
"model": "Modelo",
|
||||
"area": "Área",
|
||||
"actions": "Ações",
|
||||
"view_mode_label": "Vista",
|
||||
"view_cards": "Vista de cartões",
|
||||
"view_table": "Vista de tabela",
|
||||
"objects_table_columns_label": "Colunas da tabela de objetos",
|
||||
"objects_table_columns_hint": "Escolha que colunas aparecem na vista de tabela de objetos.",
|
||||
"custom_icon_optional": "Ícone (opcional, ex. mdi:wrench)",
|
||||
"task_enabled": "Tarefa ativada",
|
||||
"skip_reason_prompt": "Saltar esta tarefa?",
|
||||
"reason_optional": "Motivo (opcional)",
|
||||
"reset_date_prompt": "Marcar tarefa como executada?",
|
||||
"reset_date_optional": "Data da última execução (opcional, padrão: hoje)",
|
||||
"notes_label": "Notas",
|
||||
"documentation_label": "Documentação",
|
||||
"no_nfc_tag": "— Sem etiqueta —",
|
||||
"dashboard": "Painel",
|
||||
"tab_today": "Hoje",
|
||||
"palette_placeholder": "Pesquisar objetos e tarefas…",
|
||||
"palette_no_results": "Sem resultados",
|
||||
"palette_hint": "↑↓ navegar · Enter abrir · Esc fechar",
|
||||
"today_all_caught_up": "Tudo em dia! Nada para esta semana.",
|
||||
"today_overdue": "Atrasadas",
|
||||
"today_due_today": "Vence hoje",
|
||||
"today_this_week": "Esta semana",
|
||||
"settings": "Definições",
|
||||
"settings_features": "Funções avançadas",
|
||||
"settings_features_desc": "Ative ou desative funções avançadas. Desativar oculta-as da interface mas não elimina dados.",
|
||||
"feat_adaptive": "Agendamento adaptativo",
|
||||
"feat_adaptive_desc": "Aprender intervalos ideais a partir do histórico de manutenção",
|
||||
"feat_predictions": "Previsões do sensor",
|
||||
"feat_predictions_desc": "Prever datas de acionamento pela degradação do sensor",
|
||||
"feat_seasonal": "Ajustes sazonais",
|
||||
"feat_seasonal_desc": "Ajustar intervalos com base em padrões sazonais",
|
||||
"feat_environmental": "Correlação ambiental",
|
||||
"feat_environmental_desc": "Correlacionar intervalos com temperatura/humidade",
|
||||
"feat_budget": "Controlo de orçamento",
|
||||
"feat_budget_desc": "Acompanhar despesas de manutenção mensais e anuais",
|
||||
"feat_groups": "Grupos de tarefas",
|
||||
"feat_groups_desc": "Organizar tarefas em grupos lógicos",
|
||||
"feat_checklists": "Listas de verificação",
|
||||
"feat_checklists_desc": "Procedimentos com vários passos para conclusão de tarefas",
|
||||
"settings_general": "Geral",
|
||||
"settings_default_warning": "Dias de aviso predefinidos",
|
||||
"settings_panel_enabled": "Painel lateral",
|
||||
"settings_panel_title": "Título do painel lateral",
|
||||
"settings_notifications": "Notificações",
|
||||
"settings_notify_service": "Serviço de notificação",
|
||||
"test_notification": "Notificação de teste",
|
||||
"send_test": "Enviar teste",
|
||||
"testing": "A enviar…",
|
||||
"test_notification_success": "Notificação de teste enviada",
|
||||
"test_notification_failed": "Falha na notificação de teste",
|
||||
"settings_notify_due_soon": "Notificar quando próxima",
|
||||
"settings_notify_overdue": "Notificar quando atrasada",
|
||||
"settings_notify_triggered": "Notificar quando acionada",
|
||||
"settings_interval_hours": "Intervalo de repetição (horas, 0 = uma vez)",
|
||||
"settings_quiet_hours": "Horas de silêncio",
|
||||
"settings_quiet_start": "Início",
|
||||
"settings_quiet_end": "Fim",
|
||||
"settings_max_per_day": "Máx. notificações por dia (0 = ilimitado)",
|
||||
"settings_bundling": "Agrupar notificações",
|
||||
"settings_bundle_threshold": "Limiar de agrupamento",
|
||||
"settings_reminder_leads": "Extra reminders (days before due)",
|
||||
"settings_reminder_leads_hint": "Comma-separated lead times, e.g. 14, 3, 0 — one extra reminder fires on each matching day. Empty = off.",
|
||||
"settings_actions": "Botões de ação móveis",
|
||||
"settings_action_complete": "Mostrar botão 'Concluída'",
|
||||
"settings_action_skip": "Mostrar botão 'Saltar'",
|
||||
"settings_action_snooze": "Mostrar botão 'Adiar'",
|
||||
"settings_weekly_digest": "Resumo semanal",
|
||||
"settings_weekly_digest_hint": "Uma notificação de resumo na segunda de manhã quando há tarefas.",
|
||||
"settings_warranty_reminder": "Warranty expiry reminder",
|
||||
"settings_warranty_reminder_days": "Days before expiry",
|
||||
"settings_warranty_reminder_hint": "Notify once when an object's warranty is this many days from expiring.",
|
||||
"settings_snooze_hours": "Duração do adiamento (horas)",
|
||||
"settings_budget": "Orçamento",
|
||||
"settings_currency": "Moeda",
|
||||
"settings_budget_monthly": "Orçamento mensal",
|
||||
"settings_budget_yearly": "Orçamento anual",
|
||||
"settings_budget_alerts": "Alertas de orçamento",
|
||||
"settings_budget_threshold": "Limiar de alerta (%)",
|
||||
"settings_import_export": "Importar / Exportar",
|
||||
"settings_export_json": "Exportar JSON",
|
||||
"settings_export_yaml": "Exportar YAML",
|
||||
"settings_export_csv": "Exportar CSV",
|
||||
"settings_import_csv": "Importar CSV",
|
||||
"settings_import_placeholder": "Cole o conteúdo JSON ou CSV aqui…",
|
||||
"settings_import_btn": "Importar",
|
||||
"settings_import_success": "{count} objetos importados com sucesso.",
|
||||
"settings_export_success": "Exportação transferida.",
|
||||
"settings_saved": "Definição guardada.",
|
||||
"settings_include_history": "Incluir histórico",
|
||||
"sort_alphabetical": "Alfabético",
|
||||
"sort_due_soonest": "Vencimento mais próximo",
|
||||
"sort_task_count": "Quantidade de tarefas",
|
||||
"sort_area": "Área",
|
||||
"sort_assigned_user": "Usuário atribuído",
|
||||
"sort_group": "Grupo",
|
||||
"filter_label": "Filtro",
|
||||
"user_label": "Utilizador",
|
||||
"sort_label": "Ordenar",
|
||||
"group_by_label": "Agrupar por",
|
||||
"state_value_help": "Use o valor de estado HA (normalmente em minúsculas, p. ex. \"on\"/\"off\"). As maiúsculas são normalizadas ao guardar.",
|
||||
"target_changes_help": "Número de transições correspondentes antes do trigger disparar (predefinido: 1).",
|
||||
"qr_print_title": "Imprimir códigos QR",
|
||||
"qr_print_desc": "Gera uma página imprimível de códigos QR para recortar e colar nos equipamentos.",
|
||||
"qr_print_load": "Carregar objetos",
|
||||
"qr_print_filter": "Filtro",
|
||||
"qr_print_objects": "Objetos",
|
||||
"qr_print_actions": "Ações",
|
||||
"qr_print_url_mode": "Tipo de link",
|
||||
"qr_print_estimate": "Códigos QR estimados",
|
||||
"qr_print_over_limit": "máximo 200, restrinja o filtro",
|
||||
"qr_print_generate": "Gerar códigos QR",
|
||||
"qr_print_generating": "A gerar…",
|
||||
"qr_print_ready": "Códigos QR prontos",
|
||||
"qr_print_print_button": "Imprimir",
|
||||
"qr_print_empty": "Nada a gerar",
|
||||
"qr_action_skip": "Saltar",
|
||||
"vacation_title": "Modo de férias",
|
||||
"vacation_active": "ativo",
|
||||
"vacation_ended": "terminado",
|
||||
"vacation_desc": "Planeia as tuas férias: as notificações são pausadas durante o período mais dias de margem. Podes manter exceções por tarefa.",
|
||||
"vacation_enable": "Ativar modo de férias",
|
||||
"vacation_start": "Início",
|
||||
"vacation_end": "Fim",
|
||||
"vacation_buffer": "Margem (dias)",
|
||||
"vacation_exempt_title": "Notificar mesmo em férias",
|
||||
"vacation_exempt_desc": "Escolhe tarefas que devem notificar mesmo em férias (p. ex. química crítica de piscina).",
|
||||
"vacation_load_tasks": "Carregar tarefas",
|
||||
"vacation_preview_btn": "Mostrar pré-visualização",
|
||||
"vacation_preview_affected": "tarefas afetadas",
|
||||
"vacation_event_due_soon": "ficará próxima do prazo",
|
||||
"vacation_event_overdue": "ficará em atraso",
|
||||
"vacation_event_triggered_est": "possível disparo do sensor",
|
||||
"vacation_sensor_based": "(baseado em sensor)",
|
||||
"vacation_action_notify": "Notificar mesmo assim",
|
||||
"vacation_action_unsilence": "Silenciar novamente",
|
||||
"vacation_marked_complete": "Marcado como concluído",
|
||||
"vacation_marked_skip": "Saltado",
|
||||
"vacation_end_now": "Terminar férias agora",
|
||||
"groupby_none": "Sem agrupamento",
|
||||
"groupby_area": "Por área",
|
||||
"groupby_group": "Por grupo",
|
||||
"groupby_user": "Por usuário",
|
||||
"unassigned": "Não atribuído",
|
||||
"no_area": "Sem área",
|
||||
"has_overdue": "Tarefas em atraso",
|
||||
"object": "Objeto",
|
||||
"settings_panel_access": "Acesso ao painel",
|
||||
"settings_panel_access_desc": "Administradores sempre têm acesso completo. Para delegar criar, editar e excluir a não administradores específicos, ative isto e selecione-os abaixo — os demais só veem Concluir e Ignorar.",
|
||||
"settings_operator_write": "Permitir que os usuários selecionados criem, editem e excluam",
|
||||
"settings_operator_write_desc": "Desativado: apenas administradores podem alterar o conteúdo. Ativado: os usuários selecionados abaixo também têm acesso completo.",
|
||||
"no_non_admin_users": "Nenhum usuário não administrador encontrado. Adicione em Configurações → Pessoas.",
|
||||
"owner_label": "Proprietário",
|
||||
"feat_completion_actions": "Ações de conclusão",
|
||||
"feat_completion_actions_desc": "Ação HA por tarefa ao concluir + QR de conclusão rápida com valores predefinidos.",
|
||||
"on_complete_action_title": "Na conclusão: acionar ação HA (opcional)",
|
||||
"on_complete_action_desc": "Chama um serviço HA quando a tarefa é concluída — p. ex. reiniciar um contador no dispositivo.",
|
||||
"on_complete_action_service": "Serviço",
|
||||
"on_complete_action_target": "Entidade alvo",
|
||||
"on_complete_action_data": "Dados (JSON, opcional)",
|
||||
"on_complete_action_test": "Testar ação",
|
||||
"on_complete_action_test_success": "Sucesso",
|
||||
"on_complete_action_test_failed": "Falhou",
|
||||
"quick_complete_defaults_title": "Valores predefinidos de conclusão rápida (para scans QR, opcional)",
|
||||
"quick_complete_defaults_desc": "Valores predefinidos para QR de conclusão rápida. Sem eles, o QR abre o diálogo.",
|
||||
"quick_complete_defaults_notes": "Notas",
|
||||
"quick_complete_defaults_cost": "Custo",
|
||||
"quick_complete_defaults_duration": "Duração (minutos)",
|
||||
"quick_complete_defaults_feedback_none": "Sem feedback",
|
||||
"quick_complete_defaults_feedback_needed": "Era necessário",
|
||||
"quick_complete_defaults_feedback_not_needed": "Não era necessário",
|
||||
"quick_complete_success": "Concluído rapidamente",
|
||||
"trigger_replaced": "Acionador substituído",
|
||||
"add": "Adicionar",
|
||||
"show_stats": "Mostrar estatísticas + gráficos",
|
||||
"hide_stats": "Ocultar estatísticas",
|
||||
"adaptive_no_data": "Ainda não há histórico de conclusões suficiente para a análise adaptativa. Conclua esta tarefa mais algumas vezes para desbloquear as recomendações de intervalo e os gráficos de fiabilidade.",
|
||||
"suggestion_applied": "Intervalo sugerido aplicado",
|
||||
"vacation_mode": "Modo de férias",
|
||||
"vacation_status_active": "Ativo agora",
|
||||
"vacation_status_scheduled": "Agendado",
|
||||
"vacation_status_inactive": "Inativo",
|
||||
"vacation_end_now_confirm": "Terminar as férias imediatamente?",
|
||||
"vacation_exempt_count": "isentas",
|
||||
"vacation_advanced": "Avançado…",
|
||||
"vacation_open_panel": "Abrir no painel",
|
||||
"enable": "Ativar",
|
||||
"saved": "Guardado",
|
||||
"budget_monthly_set": "Definir mensal",
|
||||
"budget_yearly_set": "Definir anual",
|
||||
"budget_advanced": "Moeda, alertas…",
|
||||
"budget_open_panel": "Abrir no painel",
|
||||
"groups_empty": "Ainda sem grupos.",
|
||||
"group_new_placeholder": "Adicionar grupo…",
|
||||
"group_delete_confirm": "Eliminar o grupo \"{name}\"?",
|
||||
"groups_manage_tasks": "Gerir atribuições de tarefas…",
|
||||
"groups_open_panel": "Abrir no painel",
|
||||
"on_complete_action_target_hint": "Nota: o domínio da entidade tem de corresponder ao serviço — p. ex. 'button.press' só funciona em button.*, 'counter.increment' só em counter.*, 'input_button.press' só em input_button.* etc. Em caso de incompatibilidade, a ação falha silenciosamente (o HA regista 'Referenced entities ... missing or not currently available').",
|
||||
"show_all_objects": "Mostrar todos os objetos",
|
||||
"show_all_tasks": "Limpar filtro — mostrar todas as tarefas",
|
||||
"filter_to_overdue": "Filtrar a lista apenas para em atraso",
|
||||
"filter_to_due_soon": "Filtrar a lista apenas para a vencer em breve",
|
||||
"filter_to_triggered": "Filtrar a lista apenas para acionadas",
|
||||
"open_task": "Abrir tarefa",
|
||||
"show_details": "Mostrar histórico + estatísticas",
|
||||
"hide_details": "Ocultar detalhes",
|
||||
"history_empty": "Ainda sem histórico.",
|
||||
"history_edit_button": "Editar entrada",
|
||||
"total_cost": "Custo total",
|
||||
"times_performed": "Realizada",
|
||||
"older_entries": "mais antigas",
|
||||
"open_in_panel": "Abrir no painel Manutenção",
|
||||
"skip_reason": "Motivo para saltar (opcional)",
|
||||
"reset_to_date": "Repor última execução para",
|
||||
"delete_task_confirm": "Eliminar esta tarefa e o seu histórico?",
|
||||
"delete_object_confirm": "Eliminar este objeto e todas as suas tarefas?",
|
||||
"loading": "A carregar…",
|
||||
"archive": "Arquivar",
|
||||
"undo": "Desfazer",
|
||||
"task_archived": "Tarefa arquivada",
|
||||
"object_archived": "Objeto arquivado",
|
||||
"unarchive": "Desarquivar",
|
||||
"archived": "Arquivado",
|
||||
"show_archived": "Mostrar arquivados",
|
||||
"hide_archived": "Ocultar arquivados",
|
||||
"confirm_archive_object": "Arquivar este objeto e as suas tarefas? O histórico é mantido e pode ser restaurado mais tarde.",
|
||||
"settings_archive": "Arquivo e retenção",
|
||||
"settings_archive_desc": "Retire tarefas únicas concluídas sem as eliminar. Os itens arquivados ficam ocultos e inativos, mas mantêm histórico e custos.",
|
||||
"settings_archive_oneoff_days": "Arquivar automaticamente tarefas únicas concluídas após (dias, 0 = desligado)",
|
||||
"settings_delete_archived_oneoff_days": "Eliminar automaticamente tarefas únicas arquivadas após (dias, 0 = nunca)",
|
||||
"archive_object": "Arquivar objeto",
|
||||
"unarchive_object": "Desarquivar objeto",
|
||||
"documents": "Documentos",
|
||||
"documents_empty": "Ainda sem documentos.",
|
||||
"doc_upload": "Carregar ficheiro",
|
||||
"doc_uploading": "A carregar…",
|
||||
"doc_add_link": "Adicionar ligação",
|
||||
"doc_link_url": "URL (https://…)",
|
||||
"doc_link_title": "Título (opcional)",
|
||||
"doc_open": "Abrir",
|
||||
"doc_delete_confirm": "Eliminar «{name}»?",
|
||||
"doc_too_large": "Ficheiro demasiado grande (máx. 25 MB).",
|
||||
"doc_upload_failed": "Falha no carregamento.",
|
||||
"completion_photo_optional": "Completion photo (optional)",
|
||||
"add_photo": "Add photo",
|
||||
"uploading": "Uploading…",
|
||||
"remove": "Remove",
|
||||
"doc_deduped": "Já armazenado noutro local — partilhado, sem espaço extra.",
|
||||
"doc_dup_in_object": "Este ficheiro já está anexado a este objeto.",
|
||||
"doc_link_invalid": "Apenas ligações http/https são permitidas.",
|
||||
"doc_cat_manual": "Manual",
|
||||
"doc_cat_warranty": "Garantia",
|
||||
"doc_cat_invoice": "Fatura",
|
||||
"doc_cat_spare_parts": "Peças",
|
||||
"doc_cat_photo": "Foto",
|
||||
"doc_cat_other": "Outro",
|
||||
"doc_link_badge": "Ligação",
|
||||
"doc_storage_title": "Armazenamento de documentos",
|
||||
"doc_storage_saved": "Poupado por deduplicação",
|
||||
"doc_storage_refresh": "Atualizar",
|
||||
"doc_download": "Transferir",
|
||||
"doc_close": "Fechar",
|
||||
"doc_camera": "Tirar foto",
|
||||
"doc_drop_hint": "Solte os ficheiros aqui",
|
||||
"doc_task_none": "Nenhum documento associado a esta tarefa.",
|
||||
"doc_link_existing": "Associar um documento…",
|
||||
"doc_attach": "Associar",
|
||||
"doc_unlink": "Desassociar",
|
||||
"doc_page": "Página",
|
||||
"chart_range_7d": "7d",
|
||||
"chart_range_30d": "30d",
|
||||
"chart_range_90d": "90d",
|
||||
"chart_range_1y": "1a",
|
||||
"chart_since_service": "desde a última manutenção",
|
||||
"chart_no_stats": "Sem estatísticas de longo prazo para esta entidade — mostrando apenas valores de eventos de manutenção",
|
||||
"auto_complete_on_recovery": "Concluir automaticamente quando o sensor recuperar",
|
||||
"auto_complete_on_recovery_help": "Regista uma conclusão (define a última realização) quando o gatilho se resolve sozinho — p. ex., sal reabastecido, filtro substituído.",
|
||||
"doc_search": "Pesquisar documentos…",
|
||||
"doc_search_none": "Nenhum documento correspondente",
|
||||
"link_device_optional": "Associar a dispositivo existente (opcional)",
|
||||
"parent_object_optional": "Objeto principal (opcional)",
|
||||
"parent_none": "(Sem objeto principal)",
|
||||
"paused": "Pausado",
|
||||
"pause_object": "Pausar",
|
||||
"resume_object": "Retomar",
|
||||
"pause_until_prompt": "Congela os agendamentos deste objeto — nada vence nem notifica até ser retomado. Opcionalmente, defina uma data de retoma automática.",
|
||||
"pause_until_label": "Retomar em (opcional)",
|
||||
"object_paused": "Objeto pausado",
|
||||
"object_resumed": "Objeto retomado — agendamentos reiniciados",
|
||||
"object_paused_badge": "Pausado",
|
||||
"paused_until_label": "até",
|
||||
"replace_object": "Substituir…",
|
||||
"replace_object_prompt": "Retire este objeto e crie um sucessor. O histórico e os custos ficam arquivados no antigo; as tarefas e os documentos passam para o novo, os contadores começam do zero.",
|
||||
"replace_name_label": "Nome do sucessor",
|
||||
"object_replaced": "Objeto substituído — sucessor criado",
|
||||
"reading_unit_label": "Unidade de leitura (p. ex. kWh, m³)",
|
||||
"reading_unit_help": "Mostrada ao lado do valor registado ao concluir esta tarefa.",
|
||||
"reading_value_label": "Valor de leitura",
|
||||
"reading_label": "Leitura",
|
||||
"settings_templates_label": "Galeria de modelos",
|
||||
"settings_templates_hint": "Desmarque modelos de que nunca precisará — desaparecem dos seletores \"A partir de modelo\" (painel e config flow). Nada mais muda; reativáveis a qualquer momento.",
|
||||
"worksheet": "Folha de trabalho",
|
||||
"worksheet_scan_view": "Digitalize para abrir a tarefa",
|
||||
"worksheet_scan_complete": "Digitalize para concluir",
|
||||
"worksheet_manual_excerpt": "Excerto do manual",
|
||||
"worksheet_pages": "páginas",
|
||||
"worksheet_printed": "Impresso",
|
||||
"worksheet_never": "Nunca"
|
||||
}
|
||||
@@ -0,0 +1,696 @@
|
||||
{
|
||||
"maintenance": "Обслуживание",
|
||||
"objects": "Объекты",
|
||||
"tasks": "Задачи",
|
||||
"overdue": "Просрочено",
|
||||
"due_soon": "Скоро",
|
||||
"triggered": "Сработало",
|
||||
"ok": "OK",
|
||||
"all": "Все",
|
||||
"new_object": "+ Новый объект",
|
||||
"templates_from": "Из шаблона",
|
||||
"templates_title": "Начать с шаблона",
|
||||
"templates_task_count": "{n} задач",
|
||||
"template_created": "Создано из шаблона",
|
||||
"onboard_hint": "Добавьте первый объект, чтобы отслеживать обслуживание.",
|
||||
"edit": "Изменить",
|
||||
"duplicate": "Дублировать",
|
||||
"task_duplicated": "Задача продублирована",
|
||||
"object_duplicated": "Объект продублирован",
|
||||
"delete": "Удалить",
|
||||
"add_task": "+ Добавить задачу",
|
||||
"complete": "Выполнить",
|
||||
"completed": "Выполнено",
|
||||
"skip": "Пропустить",
|
||||
"skipped": "Пропущено",
|
||||
"missed": "Missed",
|
||||
"reset": "Сбросить",
|
||||
"snooze": "Snooze",
|
||||
"snoozed": "Snoozed",
|
||||
"cancel": "Отмена",
|
||||
"bulk_select": "Выбрать",
|
||||
"bulk_select_all": "Выбрать все",
|
||||
"bulk_n_selected": "Выбрано: {n}",
|
||||
"bulk_completed": "Выполнено задач: {n}",
|
||||
"bulk_archived": "Архивировано задач: {n}",
|
||||
"completing": "Выполнение…",
|
||||
"interval": "Интервал",
|
||||
"warning": "Предупреждение",
|
||||
"last_performed": "Последнее выполнение",
|
||||
"next_due": "Следующий срок",
|
||||
"days_until_due": "Дней до срока",
|
||||
"avg_duration": "Ср. длительность",
|
||||
"trigger": "Триггер",
|
||||
"trigger_type": "Тип триггера",
|
||||
"threshold_above": "Верхний предел",
|
||||
"threshold_below": "Нижний предел",
|
||||
"threshold": "Порог",
|
||||
"counter": "Счётчик",
|
||||
"state_change": "Изменение состояния",
|
||||
"runtime": "Время работы",
|
||||
"runtime_hours": "Целевое время работы (часы)",
|
||||
"target_value": "Целевое значение",
|
||||
"baseline": "Базовое значение",
|
||||
"target_changes": "Целевые изменения",
|
||||
"for_minutes": "На (минут)",
|
||||
"time_based": "По времени",
|
||||
"sensor_based": "По датчику",
|
||||
"manual": "Вручную",
|
||||
"one_time": "Однократно",
|
||||
"weekdays": "Дни недели",
|
||||
"nth_weekday": "N-й день недели месяца",
|
||||
"day_of_month": "День месяца",
|
||||
"recurrence_on_days": "Повторять в",
|
||||
"recurrence_occurrence": "Вхождение",
|
||||
"recurrence_weekday": "День недели",
|
||||
"recurrence_day": "День месяца (1–31)",
|
||||
"recurrence_last_day": "Last day of the month",
|
||||
"recurrence_business_day": "Business days only (roll back from weekend)",
|
||||
"recurrence_offset": "Offset (days, ±)",
|
||||
"recurrence_offset_help": "Shift the date by ±N days, e.g. -2 = two days before.",
|
||||
"last_day_month": "Last day of month",
|
||||
"last_business_day_month": "Last business day",
|
||||
"ord_1": "1-й",
|
||||
"ord_2": "2-й",
|
||||
"ord_3": "3-й",
|
||||
"ord_4": "4-й",
|
||||
"ord_5": "5-й",
|
||||
"ord_last": "Последний",
|
||||
"day_word": "День",
|
||||
"interval_value": "Интервал",
|
||||
"interval_unit": "Единица",
|
||||
"unit_days": "Дни",
|
||||
"unit_weeks": "Недели",
|
||||
"unit_months": "Месяцы",
|
||||
"unit_years": "Годы",
|
||||
"due_date": "Дата выполнения",
|
||||
"cleaning": "Чистка",
|
||||
"inspection": "Осмотр",
|
||||
"replacement": "Замена",
|
||||
"calibration": "Калибровка",
|
||||
"service": "Сервис",
|
||||
"reading": "Показания",
|
||||
"custom": "Своё",
|
||||
"history": "История",
|
||||
"cost": "Стоимость",
|
||||
"report_button": "Отчёт",
|
||||
"report_title": "Отчёт об обслуживании",
|
||||
"report_generated": "Создан",
|
||||
"report_times_done": "Выполнено",
|
||||
"report_total_cost": "Итого",
|
||||
"report_every": "каждые {n} {unit}",
|
||||
"report_notes": "Заметки",
|
||||
"report_col_type": "Тип",
|
||||
"report_col_status": "Статус",
|
||||
"report_col_schedule": "Расписание",
|
||||
"duration": "Длительность",
|
||||
"both": "Оба",
|
||||
"trigger_val": "Значение триггера",
|
||||
"complete_title": "Выполнить: ",
|
||||
"checklist": "Контрольный список",
|
||||
"checklist_steps_optional": "Шаги контрольного списка (необязательно)",
|
||||
"checklist_placeholder": "Очистить фильтр\nЗаменить уплотнитель\nПроверить давление",
|
||||
"checklist_help": "Один шаг на строку. Макс. 100 элементов.",
|
||||
"err_too_long": "{field}: слишком длинно (макс. {n} символов)",
|
||||
"err_too_short": "{field}: слишком коротко (мин. {n} символов)",
|
||||
"err_value_too_high": "{field}: слишком велико (макс. {n})",
|
||||
"err_value_too_low": "{field}: слишком мало (мин. {n})",
|
||||
"err_required": "{field}: обязательное поле",
|
||||
"err_wrong_type": "{field}: неверный тип (ожидался: {type})",
|
||||
"err_invalid_choice": "{field}: недопустимое значение",
|
||||
"err_invalid_value": "{field}: неверное значение",
|
||||
"feat_schedule_time": "Планирование по времени дня",
|
||||
"feat_schedule_time_desc": "Задачи становятся просроченными в определённое время дня, а не в полночь.",
|
||||
"schedule_time_optional": "Срок (необязательно, HH:MM)",
|
||||
"schedule_time_help": "Пусто = полночь (по умолчанию). Часовой пояс HA.",
|
||||
"at_time": "в",
|
||||
"notes_optional": "Примечания (опционально)",
|
||||
"cost_optional": "Стоимость (опционально)",
|
||||
"duration_minutes": "Длительность в минутах (опционально)",
|
||||
"days": "дней",
|
||||
"day": "день",
|
||||
"today": "Сегодня",
|
||||
"d_overdue": "дн. просрочено",
|
||||
"no_tasks": "Пока нет задач по обслуживанию. Создайте объект, чтобы начать.",
|
||||
"no_tasks_short": "Нет задач",
|
||||
"no_history": "Пока нет записей в истории.",
|
||||
"show_all": "Показать все",
|
||||
"cost_duration_chart": "Стоимость и длительность",
|
||||
"installed": "Установлен",
|
||||
"confirm_delete_object": "Удалить этот объект и все его задачи?",
|
||||
"confirm_delete_task": "Удалить эту задачу?",
|
||||
"min": "Мин",
|
||||
"max": "Макс",
|
||||
"save": "Сохранить",
|
||||
"saving": "Сохранение…",
|
||||
"edit_task": "Изменить задачу",
|
||||
"new_task": "Новая задача обслуживания",
|
||||
"task_name": "Название задачи",
|
||||
"maintenance_type": "Тип обслуживания",
|
||||
"priority": "Приоритет",
|
||||
"labels": "Labels",
|
||||
"labels_placeholder": "e.g. safety, seasonal, tenant-visible",
|
||||
"labels_help": "Comma-separated tags for filtering and reporting.",
|
||||
"priority_low": "Низкий",
|
||||
"priority_normal": "Обычный",
|
||||
"priority_high": "Высокий",
|
||||
"schedule_type": "Тип расписания",
|
||||
"interval_days": "Интервал (дни)",
|
||||
"warning_days": "Дни предупреждения",
|
||||
"earliest_completion_days": "Earliest completion (days before due)",
|
||||
"earliest_completion_days_help": "Leave empty to allow completing any time. 0 = only on/after the due date.",
|
||||
"interval_anchor": "Якорь интервала",
|
||||
"anchor_completion": "От даты выполнения",
|
||||
"anchor_planned": "От плановой даты (без смещения)",
|
||||
"edit_object": "Изменить объект",
|
||||
"name": "Имя",
|
||||
"manufacturer_optional": "Производитель (опционально)",
|
||||
"model_optional": "Модель (опционально)",
|
||||
"serial_number_optional": "Серийный номер (опционально)",
|
||||
"serial_number_label": "С/Н",
|
||||
"documentation_url_label": "Руководство",
|
||||
"object_notes_label": "Заметки",
|
||||
"sort_due_date": "Срок",
|
||||
"sort_object": "Имя объекта",
|
||||
"sort_type": "Тип",
|
||||
"sort_task_name": "Имя задачи",
|
||||
"all_objects": "Все объекты",
|
||||
"tasks_lower": "задач",
|
||||
"no_tasks_yet": "Пока нет задач",
|
||||
"add_first_task": "Добавить первую задачу",
|
||||
"last_performed_optional": "Последнее выполнение (опционально)",
|
||||
"trigger_configuration": "Настройка триггера",
|
||||
"entity_id": "ID сущности",
|
||||
"comma_separated": "через запятую",
|
||||
"entity_logic": "Логика сущностей",
|
||||
"entity_logic_any": "Любая сущность срабатывает",
|
||||
"entity_logic_all": "Все сущности должны сработать",
|
||||
"entities": "сущности",
|
||||
"attribute_optional": "Атрибут (опционально, пусто = состояние)",
|
||||
"use_entity_state": "Использовать состояние сущности (без атрибута)",
|
||||
"trigger_above": "Срабатывать выше",
|
||||
"trigger_below": "Срабатывать ниже",
|
||||
"for_at_least_minutes": "Не менее (минут)",
|
||||
"safety_interval_days": "Интервал безопасности (дни, опционально)",
|
||||
"safety_interval": "Интервал безопасности (опционально)",
|
||||
"delta_mode": "Режим дельты",
|
||||
"from_state_optional": "Из состояния (опционально)",
|
||||
"to_state_optional": "В состояние (опционально)",
|
||||
"documentation_url_optional": "URL документации (опционально)",
|
||||
"object_notes_optional": "Заметки (опционально)",
|
||||
"nfc_tag_id_optional": "ID NFC-метки (опционально)",
|
||||
"nfc_tags_empty_help": "В Home Assistant ещё не зарегистрировано NFC-тегов.",
|
||||
"nfc_tags_open_settings": "Открыть настройки тегов",
|
||||
"nfc_tags_refresh": "Обновить",
|
||||
"environmental_entity_optional": "Датчик окружающей среды (опционально)",
|
||||
"environmental_entity_helper": "напр. sensor.outdoor_temperature — корректирует интервал в зависимости от условий",
|
||||
"environmental_attribute_optional": "Атрибут среды (опционально)",
|
||||
"nfc_tag_id": "ID NFC-метки",
|
||||
"nfc_linked": "NFC-метка привязана",
|
||||
"nfc_link_hint": "Нажмите, чтобы привязать NFC-метку",
|
||||
"responsible_user": "Ответственный пользователь",
|
||||
"shared_with": "Shared with (rotation)",
|
||||
"shared_with_help": "Pick multiple people to share this task; the responsible person rotates on each completion.",
|
||||
"rotation_strategy": "Rotation",
|
||||
"rotation_none": "No rotation",
|
||||
"rotation_round_robin": "Round-robin",
|
||||
"rotation_least_completed": "Least completed",
|
||||
"rotation_random": "Random",
|
||||
"no_user_assigned": "(Не назначен)",
|
||||
"all_users": "Все пользователи",
|
||||
"my_tasks": "Мои задачи",
|
||||
"tab_calendar": "Календарь",
|
||||
"cal_no_events": "Без обслуживания",
|
||||
"cal_window_7": "7 дней",
|
||||
"cal_window_14": "14 дней",
|
||||
"cal_window_30": "30 дней",
|
||||
"cal_window_365": "1 год",
|
||||
"cal_every_n_days": "каждые {n} дней",
|
||||
"cal_source_time": "По времени",
|
||||
"cal_source_time_adaptive": "По времени (адаптивно)",
|
||||
"cal_source_sensor": "По датчику",
|
||||
"cal_predicted": "прогноз",
|
||||
"cal_confidence_high": "высокая надёжность",
|
||||
"cal_confidence_medium": "средняя надёжность",
|
||||
"cal_confidence_low": "низкая надёжность",
|
||||
"budget_monthly": "Месячный бюджет",
|
||||
"budget_yearly": "Годовой бюджет",
|
||||
"groups": "Группы",
|
||||
"new_group": "Новая группа",
|
||||
"edit_group": "Редактировать группу",
|
||||
"no_groups": "Групп пока нет",
|
||||
"delete_group": "Удалить группу",
|
||||
"delete_group_confirm": "Удалить группу '{name}'?",
|
||||
"group_select_tasks": "Выбрать задачи",
|
||||
"group_name_required": "Требуется имя",
|
||||
"description_optional": "Описание (опционально)",
|
||||
"selected": "Выбрано",
|
||||
"loading_chart": "Загрузка данных графика...",
|
||||
"hide_outliers": "Скрыть выбросы (сбои датчика)",
|
||||
"was_maintenance_needed": "Требовалось ли это обслуживание?",
|
||||
"feedback_needed": "Требовалось",
|
||||
"feedback_not_needed": "Не требовалось",
|
||||
"feedback_not_sure": "Не уверен",
|
||||
"suggested_interval": "Рекомендуемый интервал",
|
||||
"apply_suggestion": "Применить",
|
||||
"reanalyze": "Повторный анализ",
|
||||
"reanalyze_result": "Новый анализ",
|
||||
"reanalyze_insufficient_data": "Недостаточно данных для рекомендации",
|
||||
"data_points": "точек данных",
|
||||
"dismiss_suggestion": "Отклонить",
|
||||
"confidence_low": "Низкая",
|
||||
"confidence_medium": "Средняя",
|
||||
"confidence_high": "Высокая",
|
||||
"recommended": "рекомендуется",
|
||||
"seasonal_awareness": "Сезонная адаптация",
|
||||
"edit_seasonal_overrides": "Редактировать сезонные коэффициенты",
|
||||
"seasonal_overrides_title": "Сезонные коэффициенты (переопределение)",
|
||||
"seasonal_overrides_hint": "Коэффициент на месяц (0.1–5.0). Пусто = автоматически.",
|
||||
"seasonal_override_invalid": "Недопустимое значение",
|
||||
"seasonal_override_range": "Коэффициент должен быть от 0.1 до 5.0",
|
||||
"clear_all": "Очистить все",
|
||||
"seasonal_chart_title": "Сезонные факторы",
|
||||
"seasonal_learned": "Изученные",
|
||||
"seasonal_manual": "Ручные",
|
||||
"month_jan": "Янв",
|
||||
"month_feb": "Фев",
|
||||
"month_mar": "Мар",
|
||||
"month_apr": "Апр",
|
||||
"month_may": "Май",
|
||||
"month_jun": "Июн",
|
||||
"month_jul": "Июл",
|
||||
"month_aug": "Авг",
|
||||
"month_sep": "Сен",
|
||||
"month_oct": "Окт",
|
||||
"month_nov": "Ноя",
|
||||
"month_dec": "Дек",
|
||||
"sensor_prediction": "Предсказание по датчику",
|
||||
"degradation_trend": "Тренд",
|
||||
"trend_rising": "Растущий",
|
||||
"trend_falling": "Падающий",
|
||||
"trend_stable": "Стабильный",
|
||||
"trend_insufficient_data": "Недостаточно данных",
|
||||
"days_until_threshold": "Дней до порога",
|
||||
"threshold_exceeded": "Порог превышен",
|
||||
"environmental_adjustment": "Фактор среды",
|
||||
"sensor_prediction_urgency": "Датчик предсказывает порог через ~{days} дней",
|
||||
"day_short": "дн",
|
||||
"weibull_reliability_curve": "Кривая надёжности",
|
||||
"weibull_failure_probability": "Вероятность отказа",
|
||||
"weibull_r_squared": "Качество аппроксимации R²",
|
||||
"beta_early_failures": "Ранние отказы",
|
||||
"beta_random_failures": "Случайные отказы",
|
||||
"beta_wear_out": "Износ",
|
||||
"beta_highly_predictable": "Высокая предсказуемость",
|
||||
"confidence_interval": "Доверительный интервал",
|
||||
"confidence_conservative": "Консервативный",
|
||||
"confidence_aggressive": "Оптимистичный",
|
||||
"current_interval_marker": "Текущий интервал",
|
||||
"recommended_marker": "Рекомендуемый",
|
||||
"characteristic_life": "Характеристический срок службы",
|
||||
"chart_mini_sparkline": "Мини-график тренда",
|
||||
"chart_history": "История стоимости и длительности",
|
||||
"chart_seasonal": "Сезонные факторы, 12 месяцев",
|
||||
"chart_weibull": "Кривая надёжности Вейбулла",
|
||||
"chart_sparkline": "График значений триггера датчика",
|
||||
"days_progress": "Прогресс по дням",
|
||||
"qr_code": "QR-код",
|
||||
"qr_generating": "Генерация QR-кода…",
|
||||
"qr_error": "Не удалось сгенерировать QR-код.",
|
||||
"qr_error_no_url": "URL HA не настроен. Установите внешний или внутренний URL в Настройках → Система → Сеть.",
|
||||
"save_error": "Не удалось сохранить. Попробуйте ещё раз.",
|
||||
"qr_print": "Печать",
|
||||
"qr_download": "Скачать SVG",
|
||||
"qr_action": "Действие при сканировании",
|
||||
"qr_action_view": "Просмотр",
|
||||
"qr_action_complete": "Отметить обслуживание как выполненное",
|
||||
"qr_url_mode": "Тип ссылки",
|
||||
"qr_mode_companion": "Приложение-компаньон",
|
||||
"qr_mode_local": "Локальный (mDNS)",
|
||||
"qr_mode_server": "URL сервера",
|
||||
"overview": "Обзор",
|
||||
"analysis": "Анализ",
|
||||
"recent_activities": "Недавние действия",
|
||||
"search_notes": "Поиск по заметкам",
|
||||
"avg_cost": "Ср. стоимость",
|
||||
"no_advanced_features": "Расширенные функции не включены",
|
||||
"no_advanced_features_hint": "Включите «Адаптивные интервалы» или «Сезонные паттерны» в настройках интеграции, чтобы увидеть здесь аналитику.",
|
||||
"analysis_not_enough_data": "Недостаточно данных для анализа.",
|
||||
"analysis_not_enough_data_hint": "Для анализа Вейбулла требуется минимум 5 выполненных обслуживаний; сезонные паттерны становятся видны после 6+ точек данных в месяц.",
|
||||
"analysis_manual_task_hint": "Ручные задачи без интервала не генерируют аналитику.",
|
||||
"completions": "выполнений",
|
||||
"current": "Текущий",
|
||||
"shorter": "Короче",
|
||||
"longer": "Длиннее",
|
||||
"normal": "Нормальный",
|
||||
"disabled": "Отключено",
|
||||
"compound_logic": "Составная логика",
|
||||
"compound": "Составной (несколько условий)",
|
||||
"compound_logic_and": "И — должны сработать все условия",
|
||||
"compound_logic_or": "ИЛИ — достаточно любого условия",
|
||||
"compound_help": "Объедините несколько условий датчиков в один триггер.",
|
||||
"compound_no_conditions": "Условий пока нет — добавьте хотя бы одно.",
|
||||
"compound_add_condition": "Добавить условие",
|
||||
"compound_condition": "Условие",
|
||||
"compound_remove_condition": "Удалить условие",
|
||||
"card_title": "Заголовок",
|
||||
"card_show_header": "Показывать заголовок со статистикой",
|
||||
"card_show_actions": "Показывать кнопки действий",
|
||||
"card_compact": "Компактный режим",
|
||||
"card_max_items": "Макс. элементов (0 = все)",
|
||||
"card_filter_status": "Фильтровать по статусу",
|
||||
"card_filter_status_help": "Пусто = показать все статусы.",
|
||||
"card_filter_objects": "Фильтровать по объектам",
|
||||
"card_filter_objects_help": "Пусто = показать все объекты.",
|
||||
"card_filter_entities": "Фильтровать по сущностям (entity_ids)",
|
||||
"card_filter_entities_help": "Выберите сущности sensor / binary_sensor из этой интеграции. Пусто = все.",
|
||||
"card_loading_objects": "Загрузка объектов…",
|
||||
"card_load_error": "Не удалось загрузить объекты — проверьте WebSocket-соединение.",
|
||||
"card_no_tasks_title": "Пока нет задач обслуживания",
|
||||
"card_no_tasks_cta": "→ Создайте в панели Maintenance",
|
||||
"no_objects": "Пока нет объектов.",
|
||||
"action_error": "Не удалось выполнить действие. Попробуйте ещё раз.",
|
||||
"area_id_optional": "Зона (опционально)",
|
||||
"installation_date_optional": "Дата установки (опционально)",
|
||||
"warranty_expiry_optional": "Окончание гарантии (опционально)",
|
||||
"warranty": "Гарантия",
|
||||
"warranty_valid_until": "действует до {date}",
|
||||
"warranty_expires_in": "истекает через {days} дн.",
|
||||
"warranty_expired": "истекла",
|
||||
"cal_past_windows": "Прошлые окна",
|
||||
"cal_forward_windows": "Будущие окна",
|
||||
"history_edit_title": "Редактировать запись истории",
|
||||
"history_edit_timestamp": "Метка времени",
|
||||
"manufacturer": "Производитель",
|
||||
"model": "Модель",
|
||||
"area": "Зона",
|
||||
"actions": "Действия",
|
||||
"view_mode_label": "Вид",
|
||||
"view_cards": "Карточки",
|
||||
"view_table": "Таблица",
|
||||
"objects_table_columns_label": "Столбцы таблицы объектов",
|
||||
"objects_table_columns_hint": "Выберите, какие столбцы показывать в табличном виде объектов.",
|
||||
"custom_icon_optional": "Иконка (опционально, например mdi:wrench)",
|
||||
"task_enabled": "Задача включена",
|
||||
"skip_reason_prompt": "Пропустить эту задачу?",
|
||||
"reason_optional": "Причина (опционально)",
|
||||
"reset_date_prompt": "Отметить задачу как выполненную?",
|
||||
"reset_date_optional": "Дата последнего выполнения (опционально, по умолчанию: сегодня)",
|
||||
"notes_label": "Примечания",
|
||||
"documentation_label": "Документация",
|
||||
"no_nfc_tag": "— Нет метки —",
|
||||
"dashboard": "Панель",
|
||||
"tab_today": "Сегодня",
|
||||
"palette_placeholder": "Поиск объектов и задач…",
|
||||
"palette_no_results": "Нет совпадений",
|
||||
"palette_hint": "↑↓ выбор · Enter открыть · Esc закрыть",
|
||||
"today_all_caught_up": "Всё сделано! На этой неделе ничего нет.",
|
||||
"today_overdue": "Просрочено",
|
||||
"today_due_today": "Сегодня",
|
||||
"today_this_week": "На этой неделе",
|
||||
"settings": "Настройки",
|
||||
"settings_features": "Расширенные функции",
|
||||
"settings_features_desc": "Включите или отключите расширенные функции. Отключение скрывает их из интерфейса, но не удаляет данные.",
|
||||
"feat_adaptive": "Адаптивное планирование",
|
||||
"feat_adaptive_desc": "Изучать оптимальные интервалы из истории обслуживания",
|
||||
"feat_predictions": "Предсказания по датчикам",
|
||||
"feat_predictions_desc": "Предсказывать даты срабатывания по деградации датчика",
|
||||
"feat_seasonal": "Сезонные корректировки",
|
||||
"feat_seasonal_desc": "Корректировать интервалы на основе сезонных паттернов",
|
||||
"feat_environmental": "Экологическая корреляция",
|
||||
"feat_environmental_desc": "Связывать интервалы с температурой/влажностью",
|
||||
"feat_budget": "Отслеживание бюджета",
|
||||
"feat_budget_desc": "Отслеживать месячные и годовые расходы на обслуживание",
|
||||
"feat_groups": "Группы задач",
|
||||
"feat_groups_desc": "Организовывать задачи в логические группы",
|
||||
"feat_checklists": "Контрольные списки",
|
||||
"feat_checklists_desc": "Многошаговые процедуры для выполнения задачи",
|
||||
"settings_general": "Основные",
|
||||
"settings_default_warning": "Дни предупреждения по умолчанию",
|
||||
"settings_panel_enabled": "Боковая панель",
|
||||
"settings_panel_title": "Заголовок панели",
|
||||
"settings_notifications": "Уведомления",
|
||||
"settings_notify_service": "Сервис уведомлений",
|
||||
"test_notification": "Тестовое уведомление",
|
||||
"send_test": "Отправить тест",
|
||||
"testing": "Отправка…",
|
||||
"test_notification_success": "Тестовое уведомление отправлено",
|
||||
"test_notification_failed": "Не удалось отправить тестовое уведомление",
|
||||
"settings_notify_due_soon": "Уведомлять, когда срок скоро истекает",
|
||||
"settings_notify_overdue": "Уведомлять при просрочке",
|
||||
"settings_notify_triggered": "Уведомлять при срабатывании",
|
||||
"settings_interval_hours": "Интервал повторения (часы, 0 = один раз)",
|
||||
"settings_quiet_hours": "Часы тишины",
|
||||
"settings_quiet_start": "Начало",
|
||||
"settings_quiet_end": "Конец",
|
||||
"settings_max_per_day": "Макс. уведомлений в день (0 = без ограничений)",
|
||||
"settings_bundling": "Группировать уведомления",
|
||||
"settings_bundle_threshold": "Порог группировки",
|
||||
"settings_reminder_leads": "Extra reminders (days before due)",
|
||||
"settings_reminder_leads_hint": "Comma-separated lead times, e.g. 14, 3, 0 — one extra reminder fires on each matching day. Empty = off.",
|
||||
"settings_actions": "Кнопки действий в мобильном приложении",
|
||||
"settings_action_complete": "Показывать кнопку «Выполнить»",
|
||||
"settings_action_skip": "Показывать кнопку «Пропустить»",
|
||||
"settings_action_snooze": "Показывать кнопку «Отложить»",
|
||||
"settings_weekly_digest": "Еженедельная сводка",
|
||||
"settings_weekly_digest_hint": "Одно сводное уведомление в понедельник утром, когда есть задачи.",
|
||||
"settings_warranty_reminder": "Warranty expiry reminder",
|
||||
"settings_warranty_reminder_days": "Days before expiry",
|
||||
"settings_warranty_reminder_hint": "Notify once when an object's warranty is this many days from expiring.",
|
||||
"settings_snooze_hours": "Длительность откладывания (часы)",
|
||||
"settings_budget": "Бюджет",
|
||||
"settings_currency": "Валюта",
|
||||
"settings_budget_monthly": "Месячный бюджет",
|
||||
"settings_budget_yearly": "Годовой бюджет",
|
||||
"settings_budget_alerts": "Оповещения о бюджете",
|
||||
"settings_budget_threshold": "Порог оповещения (%)",
|
||||
"settings_import_export": "Импорт / Экспорт",
|
||||
"settings_export_json": "Экспорт JSON",
|
||||
"settings_export_yaml": "Экспорт YAML",
|
||||
"settings_export_csv": "Экспорт CSV",
|
||||
"settings_import_csv": "Импорт CSV",
|
||||
"settings_import_placeholder": "Вставьте содержимое JSON или CSV здесь…",
|
||||
"settings_import_btn": "Импортировать",
|
||||
"settings_import_success": "Импортировано объектов: {count}.",
|
||||
"settings_export_success": "Экспорт загружен.",
|
||||
"settings_saved": "Настройка сохранена.",
|
||||
"settings_include_history": "Включать историю",
|
||||
"sort_alphabetical": "По алфавиту",
|
||||
"sort_due_soonest": "Ближайший срок",
|
||||
"sort_task_count": "Количество задач",
|
||||
"sort_area": "Область",
|
||||
"sort_assigned_user": "Назначенный пользователь",
|
||||
"sort_group": "Группа",
|
||||
"groupby_none": "Без группировки",
|
||||
"groupby_area": "По области",
|
||||
"groupby_group": "По группе",
|
||||
"groupby_user": "По пользователю",
|
||||
"filter_label": "Фильтр",
|
||||
"user_label": "Пользователь",
|
||||
"sort_label": "Сортировка",
|
||||
"group_by_label": "Группировать по",
|
||||
"state_value_help": "Используйте значение состояния HA (обычно в нижнем регистре, напр. \"on\"/\"off\"). Регистр нормализуется при сохранении.",
|
||||
"target_changes_help": "Количество совпадающих переходов, после которых срабатывает триггер (по умолчанию: 1).",
|
||||
"qr_print_title": "Печать QR-кодов",
|
||||
"qr_print_desc": "Создай страницу для печати с QR-кодами для вырезания и наклеивания на оборудование.",
|
||||
"qr_print_load": "Загрузить объекты",
|
||||
"qr_print_filter": "Фильтр",
|
||||
"qr_print_objects": "Объекты",
|
||||
"qr_print_actions": "Действия",
|
||||
"qr_print_url_mode": "Тип ссылки",
|
||||
"qr_print_estimate": "Оценка QR-кодов",
|
||||
"qr_print_over_limit": "лимит 200, сузь фильтр",
|
||||
"qr_print_generate": "Создать QR-коды",
|
||||
"qr_print_generating": "Создание…",
|
||||
"qr_print_ready": "QR-коды готовы",
|
||||
"qr_print_print_button": "Печать",
|
||||
"qr_print_empty": "Нечего создавать",
|
||||
"qr_action_skip": "Пропустить",
|
||||
"vacation_title": "Режим отпуска",
|
||||
"vacation_active": "активен",
|
||||
"vacation_ended": "завершён",
|
||||
"vacation_desc": "Запланируй отпуск: уведомления приостанавливаются на период плюс несколько буферных дней. Можно задать исключения по задачам.",
|
||||
"vacation_enable": "Включить режим отпуска",
|
||||
"vacation_start": "Начало",
|
||||
"vacation_end": "Конец",
|
||||
"vacation_buffer": "Буфер (дней)",
|
||||
"vacation_exempt_title": "Уведомлять несмотря на отпуск",
|
||||
"vacation_exempt_desc": "Выбери задачи, по которым нужно уведомлять и в отпуске (например, критичная химия бассейна).",
|
||||
"vacation_load_tasks": "Загрузить задачи",
|
||||
"vacation_preview_btn": "Показать предпросмотр",
|
||||
"vacation_preview_affected": "задач затронуто",
|
||||
"vacation_event_due_soon": "скоро наступит срок",
|
||||
"vacation_event_overdue": "станет просроченной",
|
||||
"vacation_event_triggered_est": "возможно срабатывание сенсора",
|
||||
"vacation_sensor_based": "(сенсорная)",
|
||||
"vacation_action_notify": "Всё равно уведомлять",
|
||||
"vacation_action_unsilence": "Снова заглушить",
|
||||
"vacation_marked_complete": "Отмечено как выполнено",
|
||||
"vacation_marked_skip": "Пропущено",
|
||||
"vacation_end_now": "Завершить отпуск сейчас",
|
||||
"unassigned": "Не назначено",
|
||||
"no_area": "Без области",
|
||||
"has_overdue": "Просроченные задачи",
|
||||
"object": "Объект",
|
||||
"settings_panel_access": "Доступ к панели",
|
||||
"settings_panel_access_desc": "Администраторы всегда имеют полный доступ. Чтобы делегировать создание, редактирование и удаление определённым не-админ пользователям, включите это и выберите их ниже — остальные видят только Выполнить и Пропустить.",
|
||||
"settings_operator_write": "Разрешить выбранным пользователям создавать, редактировать и удалять",
|
||||
"settings_operator_write_desc": "Выключено: только администраторы могут изменять содержимое. Включено: выбранные пользователи ниже также получают полный доступ.",
|
||||
"no_non_admin_users": "Не найдено не-админ пользователей. Добавьте их в Настройках → Люди.",
|
||||
"owner_label": "Владелец",
|
||||
"feat_completion_actions": "Действия при завершении",
|
||||
"feat_completion_actions_desc": "HA-действие по задаче при завершении + QR быстрого завершения с предустановленными значениями.",
|
||||
"on_complete_action_title": "При завершении: запустить HA-действие (опционально)",
|
||||
"on_complete_action_desc": "Вызывает HA-сервис при завершении задачи — напр., сбросить счётчик на устройстве.",
|
||||
"on_complete_action_service": "Сервис",
|
||||
"on_complete_action_target": "Целевая сущность",
|
||||
"on_complete_action_data": "Данные (JSON, опционально)",
|
||||
"on_complete_action_test": "Тестировать действие",
|
||||
"on_complete_action_test_success": "Успешно",
|
||||
"on_complete_action_test_failed": "Ошибка",
|
||||
"quick_complete_defaults_title": "Значения по умолчанию для быстрого завершения (для QR-сканов, опционально)",
|
||||
"quick_complete_defaults_desc": "Предустановленные значения для QR быстрого завершения. Без них QR открывает диалог.",
|
||||
"quick_complete_defaults_notes": "Заметки",
|
||||
"quick_complete_defaults_cost": "Стоимость",
|
||||
"quick_complete_defaults_duration": "Длительность (минут)",
|
||||
"quick_complete_defaults_feedback_none": "Без отзыва",
|
||||
"quick_complete_defaults_feedback_needed": "Было необходимо",
|
||||
"quick_complete_defaults_feedback_not_needed": "Не было необходимо",
|
||||
"quick_complete_success": "Быстро отмечено как выполнено",
|
||||
"trigger_replaced": "Триггер заменён",
|
||||
"add": "Добавить",
|
||||
"show_stats": "Показать статистику + графики",
|
||||
"hide_stats": "Скрыть статистику",
|
||||
"adaptive_no_data": "Пока недостаточно истории выполнений для адаптивного анализа. Выполните эту задачу ещё несколько раз, чтобы разблокировать рекомендации по интервалу и графики надёжности.",
|
||||
"suggestion_applied": "Предлагаемый интервал применён",
|
||||
"vacation_mode": "Режим отпуска",
|
||||
"vacation_status_active": "Активен сейчас",
|
||||
"vacation_status_scheduled": "Запланирован",
|
||||
"vacation_status_inactive": "Неактивен",
|
||||
"vacation_end_now_confirm": "Завершить отпуск немедленно?",
|
||||
"vacation_exempt_count": "исключено",
|
||||
"vacation_advanced": "Дополнительно…",
|
||||
"vacation_open_panel": "Открыть на панели",
|
||||
"enable": "Включить",
|
||||
"saved": "Сохранено",
|
||||
"budget_monthly_set": "Задать месячный",
|
||||
"budget_yearly_set": "Задать годовой",
|
||||
"budget_advanced": "Валюта, оповещения…",
|
||||
"budget_open_panel": "Открыть на панели",
|
||||
"groups_empty": "Пока нет групп.",
|
||||
"group_new_placeholder": "Добавить группу…",
|
||||
"group_delete_confirm": "Удалить группу «{name}»?",
|
||||
"groups_manage_tasks": "Управление назначениями задач…",
|
||||
"groups_open_panel": "Открыть на панели",
|
||||
"on_complete_action_target_hint": "Примечание: домен сущности должен соответствовать сервису — напр. 'button.press' работает только с button.*, 'counter.increment' только с counter.*, 'input_button.press' только с input_button.* и т. д. При несоответствии действие молча не выполнится (HA запишет в журнал 'Referenced entities ... missing or not currently available').",
|
||||
"show_all_objects": "Показать все объекты",
|
||||
"show_all_tasks": "Сбросить фильтр — показать все задачи",
|
||||
"filter_to_overdue": "Фильтровать список только по просроченным",
|
||||
"filter_to_due_soon": "Фильтровать список только по скоро наступающим",
|
||||
"filter_to_triggered": "Фильтровать список только по сработавшим",
|
||||
"open_task": "Открыть задачу",
|
||||
"show_details": "Показать историю + статистику",
|
||||
"hide_details": "Скрыть детали",
|
||||
"history_empty": "Пока нет истории.",
|
||||
"history_edit_button": "Изменить запись",
|
||||
"total_cost": "Общая стоимость",
|
||||
"times_performed": "Выполнено",
|
||||
"older_entries": "ранее",
|
||||
"open_in_panel": "Открыть на панели обслуживания",
|
||||
"skip_reason": "Причина пропуска (необязательно)",
|
||||
"reset_to_date": "Сбросить последнее выполнение на",
|
||||
"delete_task_confirm": "Удалить эту задачу и её историю?",
|
||||
"delete_object_confirm": "Удалить этот объект и все его задачи?",
|
||||
"loading": "Загрузка…",
|
||||
"archive": "Архивировать",
|
||||
"undo": "Отменить",
|
||||
"task_archived": "Задача архивирована",
|
||||
"object_archived": "Объект архивирован",
|
||||
"unarchive": "Разархивировать",
|
||||
"archived": "В архиве",
|
||||
"show_archived": "Показать архив",
|
||||
"hide_archived": "Скрыть архив",
|
||||
"confirm_archive_object": "Архивировать этот объект и его задачи? История сохранится, позже можно восстановить.",
|
||||
"settings_archive": "Архив и хранение",
|
||||
"settings_archive_desc": "Убирайте выполненные разовые задачи в архив, не удаляя их. Архивные элементы скрыты и неактивны, но сохраняют историю и расходы.",
|
||||
"settings_archive_oneoff_days": "Автоархивация выполненных разовых задач через (дней, 0 = выкл)",
|
||||
"settings_delete_archived_oneoff_days": "Автоудаление архивных разовых задач через (дней, 0 = никогда)",
|
||||
"archive_object": "Архивировать объект",
|
||||
"unarchive_object": "Разархивировать объект",
|
||||
"documents": "Документы",
|
||||
"documents_empty": "Пока нет документов.",
|
||||
"doc_upload": "Загрузить файл",
|
||||
"doc_uploading": "Загрузка…",
|
||||
"doc_add_link": "Добавить ссылку",
|
||||
"doc_link_url": "URL (https://…)",
|
||||
"doc_link_title": "Название (необязательно)",
|
||||
"doc_open": "Открыть",
|
||||
"doc_delete_confirm": "Удалить «{name}»?",
|
||||
"doc_too_large": "Файл слишком большой (макс. 25 МБ).",
|
||||
"doc_upload_failed": "Не удалось загрузить.",
|
||||
"completion_photo_optional": "Completion photo (optional)",
|
||||
"add_photo": "Add photo",
|
||||
"uploading": "Uploading…",
|
||||
"remove": "Remove",
|
||||
"doc_deduped": "Уже сохранён в другом месте — общий, без доп. места.",
|
||||
"doc_dup_in_object": "Этот файл уже прикреплён к этому объекту.",
|
||||
"doc_link_invalid": "Разрешены только ссылки http/https.",
|
||||
"doc_cat_manual": "Инструкция",
|
||||
"doc_cat_warranty": "Гарантия",
|
||||
"doc_cat_invoice": "Счёт",
|
||||
"doc_cat_spare_parts": "Запчасти",
|
||||
"doc_cat_photo": "Фото",
|
||||
"doc_cat_other": "Другое",
|
||||
"doc_link_badge": "Ссылка",
|
||||
"doc_storage_title": "Хранилище документов",
|
||||
"doc_storage_saved": "Сэкономлено дедупликацией",
|
||||
"doc_storage_refresh": "Обновить",
|
||||
"doc_download": "Скачать",
|
||||
"doc_close": "Закрыть",
|
||||
"doc_camera": "Сделать фото",
|
||||
"doc_drop_hint": "Перетащите файлы сюда",
|
||||
"doc_task_none": "Нет документов, связанных с этой задачей.",
|
||||
"doc_link_existing": "Связать документ…",
|
||||
"doc_attach": "Связать",
|
||||
"doc_unlink": "Отвязать",
|
||||
"doc_page": "Страница",
|
||||
"chart_range_7d": "7д",
|
||||
"chart_range_30d": "30д",
|
||||
"chart_range_90d": "90д",
|
||||
"chart_range_1y": "1г",
|
||||
"chart_since_service": "с последнего обслуживания",
|
||||
"chart_no_stats": "Нет долгосрочной статистики для этой сущности — показаны только значения из записей обслуживания",
|
||||
"auto_complete_on_recovery": "Автозавершение при восстановлении датчика",
|
||||
"auto_complete_on_recovery_help": "Записывает выполнение (обновляет дату последнего обслуживания), когда триггер устраняется сам — например, соль досыпана, фильтр заменён.",
|
||||
"doc_search": "Поиск документов…",
|
||||
"doc_search_none": "Нет подходящих документов",
|
||||
"link_device_optional": "Привязать к существующему устройству (необязательно)",
|
||||
"parent_object_optional": "Родительский объект (необязательно)",
|
||||
"parent_none": "(Без родителя)",
|
||||
"paused": "Приостановлено",
|
||||
"pause_object": "Приостановить",
|
||||
"resume_object": "Возобновить",
|
||||
"pause_until_prompt": "Заморозить расписания этого объекта — ничего не наступает и не уведомляет до возобновления. При желании укажите дату автоматического возобновления.",
|
||||
"pause_until_label": "Возобновить (необязательно)",
|
||||
"object_paused": "Объект приостановлен",
|
||||
"object_resumed": "Объект возобновлён — расписания перезапущены",
|
||||
"object_paused_badge": "Приостановлено",
|
||||
"paused_until_label": "до",
|
||||
"replace_object": "Заменить…",
|
||||
"replace_object_prompt": "Списать этот объект и создать преемника. История и затраты остаются в архиве старого; задачи и документы переходят к новому, счётчики начинаются заново.",
|
||||
"replace_name_label": "Имя преемника",
|
||||
"object_replaced": "Объект заменён — преемник создан",
|
||||
"reading_unit_label": "Единица показания (напр. кВт·ч, м³)",
|
||||
"reading_unit_help": "Отображается рядом с записанным значением при выполнении этой задачи.",
|
||||
"reading_value_label": "Показание",
|
||||
"reading_label": "Показание",
|
||||
"settings_templates_label": "Галерея шаблонов",
|
||||
"settings_templates_hint": "Снимите отметку с шаблонов, которые вам не нужны — они исчезнут из выбора «Из шаблона» (панель и config flow). Больше ничего не меняется; можно включить обратно в любой момент.",
|
||||
"worksheet": "Рабочий лист",
|
||||
"worksheet_scan_view": "Сканируйте, чтобы открыть задачу",
|
||||
"worksheet_scan_complete": "Сканируйте для выполнения",
|
||||
"worksheet_manual_excerpt": "Выдержка из руководства",
|
||||
"worksheet_pages": "страницы",
|
||||
"worksheet_printed": "Напечатано",
|
||||
"worksheet_never": "Никогда"
|
||||
}
|
||||
@@ -0,0 +1,696 @@
|
||||
{
|
||||
"maintenance": "Underhåll",
|
||||
"objects": "Objekt",
|
||||
"tasks": "Uppgifter",
|
||||
"overdue": "Försenad",
|
||||
"due_soon": "Snart",
|
||||
"triggered": "Utlöst",
|
||||
"ok": "OK",
|
||||
"all": "Alla",
|
||||
"new_object": "+ Nytt objekt",
|
||||
"templates_from": "Från mall",
|
||||
"templates_title": "Börja från en mall",
|
||||
"templates_task_count": "{n} uppgifter",
|
||||
"template_created": "Skapad från mall",
|
||||
"onboard_hint": "Lägg till ditt första objekt för att spåra underhåll.",
|
||||
"edit": "Redigera",
|
||||
"duplicate": "Duplicera",
|
||||
"task_duplicated": "Uppgift duplicerad",
|
||||
"object_duplicated": "Objekt duplicerat",
|
||||
"delete": "Ta bort",
|
||||
"add_task": "+ Lägg till uppgift",
|
||||
"complete": "Slutför",
|
||||
"completed": "Slutförd",
|
||||
"skip": "Hoppa över",
|
||||
"skipped": "Hoppade över",
|
||||
"missed": "Missed",
|
||||
"reset": "Återställ",
|
||||
"snooze": "Snooze",
|
||||
"snoozed": "Snoozed",
|
||||
"cancel": "Avbryt",
|
||||
"bulk_select": "Välj",
|
||||
"bulk_select_all": "Välj alla",
|
||||
"bulk_n_selected": "{n} valda",
|
||||
"bulk_completed": "{n} uppgifter slutförda",
|
||||
"bulk_archived": "{n} uppgifter arkiverade",
|
||||
"completing": "Slutför…",
|
||||
"interval": "Intervall",
|
||||
"warning": "Varning",
|
||||
"last_performed": "Senast utförd",
|
||||
"next_due": "Nästa förfallodatum",
|
||||
"days_until_due": "Dagar till förfallodatum",
|
||||
"avg_duration": "Snittlig varaktighet",
|
||||
"trigger": "Utlösare",
|
||||
"trigger_type": "Utlösartyp",
|
||||
"threshold_above": "Övre gräns",
|
||||
"threshold_below": "Undre gräns",
|
||||
"threshold": "Tröskel",
|
||||
"counter": "Räknare",
|
||||
"state_change": "Tillståndsändring",
|
||||
"runtime": "Körtid",
|
||||
"runtime_hours": "Måltid (timmar)",
|
||||
"target_value": "Målvärde",
|
||||
"baseline": "Baslinje",
|
||||
"target_changes": "Antal målförändringar",
|
||||
"for_minutes": "Under (minuter)",
|
||||
"time_based": "Tidsbaserad",
|
||||
"sensor_based": "Sensorbaserad",
|
||||
"manual": "Manuell",
|
||||
"one_time": "Engångs",
|
||||
"weekdays": "Veckodagar",
|
||||
"nth_weekday": "N:te veckodag i månaden",
|
||||
"day_of_month": "Dag i månaden",
|
||||
"recurrence_on_days": "Upprepa på",
|
||||
"recurrence_occurrence": "Förekomst",
|
||||
"recurrence_weekday": "Veckodag",
|
||||
"recurrence_day": "Dag i månaden (1–31)",
|
||||
"recurrence_last_day": "Last day of the month",
|
||||
"recurrence_business_day": "Business days only (roll back from weekend)",
|
||||
"recurrence_offset": "Offset (days, ±)",
|
||||
"recurrence_offset_help": "Shift the date by ±N days, e.g. -2 = two days before.",
|
||||
"last_day_month": "Last day of month",
|
||||
"last_business_day_month": "Last business day",
|
||||
"ord_1": "1:a",
|
||||
"ord_2": "2:a",
|
||||
"ord_3": "3:e",
|
||||
"ord_4": "4:e",
|
||||
"ord_5": "5:e",
|
||||
"ord_last": "Sista",
|
||||
"day_word": "Dag",
|
||||
"interval_value": "Intervall",
|
||||
"interval_unit": "Enhet",
|
||||
"unit_days": "Dagar",
|
||||
"unit_weeks": "Veckor",
|
||||
"unit_months": "Månader",
|
||||
"unit_years": "År",
|
||||
"due_date": "Förfallodatum",
|
||||
"cleaning": "Rengöring",
|
||||
"inspection": "Inspektion",
|
||||
"replacement": "Byte",
|
||||
"calibration": "Kalibrering",
|
||||
"service": "Service",
|
||||
"reading": "Avläsning",
|
||||
"custom": "Anpassad",
|
||||
"history": "Historik",
|
||||
"cost": "Kostnad",
|
||||
"report_button": "Rapport",
|
||||
"report_title": "Underhållsrapport",
|
||||
"report_generated": "Genererad",
|
||||
"report_times_done": "Utfört",
|
||||
"report_total_cost": "Total kostnad",
|
||||
"report_every": "var {n}:e {unit}",
|
||||
"report_notes": "Anteckningar",
|
||||
"report_col_type": "Typ",
|
||||
"report_col_status": "Status",
|
||||
"report_col_schedule": "Schema",
|
||||
"duration": "Varaktighet",
|
||||
"both": "Båda",
|
||||
"trigger_val": "Utlösarvärde",
|
||||
"complete_title": "Slutför: ",
|
||||
"checklist": "Checklista",
|
||||
"checklist_steps_optional": "Checkliststeg (valfritt)",
|
||||
"checklist_placeholder": "Rengör filter\nByt tätning\nTesta tryck",
|
||||
"checklist_help": "Ett steg per rad. Max 100 objekt.",
|
||||
"err_too_long": "{field}: för lång (max {n} tecken)",
|
||||
"err_too_short": "{field}: för kort (min {n} tecken)",
|
||||
"err_value_too_high": "{field}: för stor (max {n})",
|
||||
"err_value_too_low": "{field}: för liten (min {n})",
|
||||
"err_required": "{field}: krävs",
|
||||
"err_wrong_type": "{field}: fel typ (förväntad: {type})",
|
||||
"err_invalid_choice": "{field}: ej tillåtet värde",
|
||||
"err_invalid_value": "{field}: ogiltigt värde",
|
||||
"feat_schedule_time": "Schemaläggning per tid på dygnet",
|
||||
"feat_schedule_time_desc": "Uppgifter blir försenade vid en specifik tid på dygnet istället för midnatt.",
|
||||
"schedule_time_optional": "Förfaller kl. (valfritt, HH:MM)",
|
||||
"schedule_time_help": "Tomt = midnatt (standard). HA-tidszon.",
|
||||
"at_time": "kl.",
|
||||
"notes_optional": "Anteckningar (valfritt)",
|
||||
"cost_optional": "Kostnad (valfritt)",
|
||||
"duration_minutes": "Varaktighet i minuter (valfritt)",
|
||||
"days": "dagar",
|
||||
"day": "dag",
|
||||
"today": "Idag",
|
||||
"d_overdue": "d försenad",
|
||||
"no_tasks": "Inga underhållsuppgifter ännu. Skapa ett objekt för att komma igång.",
|
||||
"no_tasks_short": "Inga uppgifter",
|
||||
"no_history": "Inga historikposter ännu.",
|
||||
"show_all": "Visa alla",
|
||||
"cost_duration_chart": "Kostnad och varaktighet",
|
||||
"installed": "Installerad",
|
||||
"confirm_delete_object": "Ta bort detta objekt och alla dess uppgifter?",
|
||||
"confirm_delete_task": "Ta bort denna uppgift?",
|
||||
"min": "Min",
|
||||
"max": "Max",
|
||||
"save": "Spara",
|
||||
"saving": "Sparar…",
|
||||
"edit_task": "Redigera uppgift",
|
||||
"new_task": "Ny underhållsuppgift",
|
||||
"task_name": "Uppgiftsnamn",
|
||||
"maintenance_type": "Underhållstyp",
|
||||
"priority": "Prioritet",
|
||||
"labels": "Labels",
|
||||
"labels_placeholder": "e.g. safety, seasonal, tenant-visible",
|
||||
"labels_help": "Comma-separated tags for filtering and reporting.",
|
||||
"priority_low": "Låg",
|
||||
"priority_normal": "Normal",
|
||||
"priority_high": "Hög",
|
||||
"schedule_type": "Schematyp",
|
||||
"interval_days": "Intervall (dagar)",
|
||||
"warning_days": "Varningsdagar",
|
||||
"earliest_completion_days": "Earliest completion (days before due)",
|
||||
"earliest_completion_days_help": "Leave empty to allow completing any time. 0 = only on/after the due date.",
|
||||
"last_performed_optional": "Senast utförd (valfritt)",
|
||||
"interval_anchor": "Intervallankare",
|
||||
"anchor_completion": "Från slutförandedatum",
|
||||
"anchor_planned": "Från planerat datum (ingen drift)",
|
||||
"edit_object": "Redigera objekt",
|
||||
"name": "Namn",
|
||||
"manufacturer_optional": "Tillverkare (valfritt)",
|
||||
"model_optional": "Modell (valfritt)",
|
||||
"serial_number_optional": "Serienummer (valfritt)",
|
||||
"serial_number_label": "S/N",
|
||||
"documentation_url_label": "Manual",
|
||||
"object_notes_label": "Anteckningar",
|
||||
"sort_due_date": "Förfallodatum",
|
||||
"sort_object": "Objektnamn",
|
||||
"sort_type": "Typ",
|
||||
"sort_task_name": "Uppgiftsnamn",
|
||||
"all_objects": "Alla objekt",
|
||||
"tasks_lower": "uppgifter",
|
||||
"no_tasks_yet": "Inga uppgifter ännu",
|
||||
"add_first_task": "Lägg till första uppgift",
|
||||
"trigger_configuration": "Utlösarkonfiguration",
|
||||
"entity_id": "Entitets-ID",
|
||||
"comma_separated": "kommaseparerad",
|
||||
"entity_logic": "Entitetslogik",
|
||||
"entity_logic_any": "Vilken entitet som helst utlöser",
|
||||
"entity_logic_all": "Alla entiteter måste utlösa",
|
||||
"entities": "entiteter",
|
||||
"attribute_optional": "Attribut (valfritt, tomt = tillstånd)",
|
||||
"use_entity_state": "Använd entitetstillstånd (inget attribut)",
|
||||
"trigger_above": "Utlös över",
|
||||
"trigger_below": "Utlös under",
|
||||
"for_at_least_minutes": "Under minst (minuter)",
|
||||
"safety_interval_days": "Säkerhetsintervall (dagar, valfritt)",
|
||||
"safety_interval": "Säkerhetsintervall (valfritt)",
|
||||
"delta_mode": "Delta-läge",
|
||||
"from_state_optional": "Från tillstånd (valfritt)",
|
||||
"to_state_optional": "Till tillstånd (valfritt)",
|
||||
"documentation_url_optional": "Dokumentations-URL (valfritt)",
|
||||
"object_notes_optional": "Anteckningar (valfritt)",
|
||||
"nfc_tag_id_optional": "NFC-tagg-ID (valfritt)",
|
||||
"nfc_tags_empty_help": "Inga NFC-taggar registrerade i Home Assistant än.",
|
||||
"nfc_tags_open_settings": "Öppna tag-inställningar",
|
||||
"nfc_tags_refresh": "Uppdatera",
|
||||
"environmental_entity_optional": "Miljösensor (valfritt)",
|
||||
"environmental_entity_helper": "t.ex. sensor.outdoor_temperature — justerar intervallet baserat på miljöförhållanden",
|
||||
"environmental_attribute_optional": "Miljöattribut (valfritt)",
|
||||
"nfc_tag_id": "NFC-tagg-ID",
|
||||
"nfc_linked": "NFC-tagg länkad",
|
||||
"nfc_link_hint": "Klicka för att länka NFC-tagg",
|
||||
"responsible_user": "Ansvarig användare",
|
||||
"shared_with": "Shared with (rotation)",
|
||||
"shared_with_help": "Pick multiple people to share this task; the responsible person rotates on each completion.",
|
||||
"rotation_strategy": "Rotation",
|
||||
"rotation_none": "No rotation",
|
||||
"rotation_round_robin": "Round-robin",
|
||||
"rotation_least_completed": "Least completed",
|
||||
"rotation_random": "Random",
|
||||
"no_user_assigned": "(Ingen användare tilldelad)",
|
||||
"all_users": "Alla användare",
|
||||
"my_tasks": "Mina uppgifter",
|
||||
"tab_calendar": "Kalender",
|
||||
"cal_no_events": "Inget underhåll",
|
||||
"cal_window_7": "7 dagar",
|
||||
"cal_window_14": "14 dagar",
|
||||
"cal_window_30": "30 dagar",
|
||||
"cal_window_365": "1 år",
|
||||
"cal_every_n_days": "var {n} dag",
|
||||
"cal_source_time": "Tidsbaserad",
|
||||
"cal_source_time_adaptive": "Tidsbaserad (adaptiv)",
|
||||
"cal_source_sensor": "Sensorbaserad",
|
||||
"cal_predicted": "förutsagt",
|
||||
"cal_confidence_high": "hög säkerhet",
|
||||
"cal_confidence_medium": "medelhög säkerhet",
|
||||
"cal_confidence_low": "låg säkerhet",
|
||||
"budget_monthly": "Månatlig budget",
|
||||
"budget_yearly": "Årlig budget",
|
||||
"groups": "Grupper",
|
||||
"new_group": "Ny grupp",
|
||||
"edit_group": "Redigera grupp",
|
||||
"no_groups": "Inga grupper ännu",
|
||||
"delete_group": "Ta bort grupp",
|
||||
"delete_group_confirm": "Ta bort grupp '{name}'?",
|
||||
"group_select_tasks": "Välj uppgifter",
|
||||
"group_name_required": "Namn krävs",
|
||||
"description_optional": "Beskrivning (valfritt)",
|
||||
"selected": "Valda",
|
||||
"loading_chart": "Laddar diagramdata...",
|
||||
"hide_outliers": "Dölj avvikare (sensorfel)",
|
||||
"was_maintenance_needed": "Behövdes detta underhåll?",
|
||||
"feedback_needed": "Behövdes",
|
||||
"feedback_not_needed": "Behövdes inte",
|
||||
"feedback_not_sure": "Osäker",
|
||||
"suggested_interval": "Föreslaget intervall",
|
||||
"apply_suggestion": "Tillämpa",
|
||||
"reanalyze": "Analysera igen",
|
||||
"reanalyze_result": "Ny analys",
|
||||
"reanalyze_insufficient_data": "Otillräckligt med data för rekommendation",
|
||||
"data_points": "datapunkter",
|
||||
"dismiss_suggestion": "Avvisa",
|
||||
"confidence_low": "Låg",
|
||||
"confidence_medium": "Medel",
|
||||
"confidence_high": "Hög",
|
||||
"recommended": "rekommenderad",
|
||||
"seasonal_awareness": "Säsongsmedvetenhet",
|
||||
"edit_seasonal_overrides": "Redigera säsongsfaktorer",
|
||||
"seasonal_overrides_title": "Säsongsfaktorer (åsidosätt)",
|
||||
"seasonal_overrides_hint": "Faktor per månad (0.1–5.0). Tomt = lärt automatiskt.",
|
||||
"seasonal_override_invalid": "Ogiltigt värde",
|
||||
"seasonal_override_range": "Faktor måste vara mellan 0.1 och 5.0",
|
||||
"clear_all": "Rensa alla",
|
||||
"seasonal_chart_title": "Säsongsfaktorer",
|
||||
"seasonal_learned": "Lärt",
|
||||
"seasonal_manual": "Manuell",
|
||||
"month_jan": "Jan",
|
||||
"month_feb": "Feb",
|
||||
"month_mar": "Mar",
|
||||
"month_apr": "Apr",
|
||||
"month_may": "Maj",
|
||||
"month_jun": "Jun",
|
||||
"month_jul": "Jul",
|
||||
"month_aug": "Aug",
|
||||
"month_sep": "Sep",
|
||||
"month_oct": "Okt",
|
||||
"month_nov": "Nov",
|
||||
"month_dec": "Dec",
|
||||
"sensor_prediction": "Sensorprediktion",
|
||||
"degradation_trend": "Trend",
|
||||
"trend_rising": "Stigande",
|
||||
"trend_falling": "Fallande",
|
||||
"trend_stable": "Stabil",
|
||||
"trend_insufficient_data": "Otillräcklig data",
|
||||
"days_until_threshold": "Dagar till tröskel",
|
||||
"threshold_exceeded": "Tröskel överskriden",
|
||||
"environmental_adjustment": "Miljöfaktor",
|
||||
"sensor_prediction_urgency": "Sensor förutsäger tröskel om ~{days} dagar",
|
||||
"day_short": "d",
|
||||
"weibull_reliability_curve": "Tillförlitlighetskurva",
|
||||
"weibull_failure_probability": "Felsannolikhet",
|
||||
"weibull_r_squared": "Anpassning R²",
|
||||
"beta_early_failures": "Tidiga fel",
|
||||
"beta_random_failures": "Slumpmässiga fel",
|
||||
"beta_wear_out": "Slitage",
|
||||
"beta_highly_predictable": "Mycket förutsägbar",
|
||||
"confidence_interval": "Konfidensintervall",
|
||||
"confidence_conservative": "Konservativ",
|
||||
"confidence_aggressive": "Optimistisk",
|
||||
"current_interval_marker": "Aktuellt intervall",
|
||||
"recommended_marker": "Rekommenderat",
|
||||
"characteristic_life": "Karakteristisk livslängd",
|
||||
"chart_mini_sparkline": "Trenddiagram",
|
||||
"chart_history": "Kostnads- och varaktighetshistorik",
|
||||
"chart_seasonal": "Säsongsfaktorer, 12 månader",
|
||||
"chart_weibull": "Weibull tillförlitlighetskurva",
|
||||
"chart_sparkline": "Sensorutlösarvärdesdiagram",
|
||||
"days_progress": "Dagsförlopp",
|
||||
"qr_code": "QR-kod",
|
||||
"qr_generating": "Genererar QR-kod…",
|
||||
"qr_error": "Kunde inte generera QR-kod.",
|
||||
"qr_error_no_url": "Ingen HA-URL konfigurerad. Ange en extern eller intern URL i Inställningar → System → Nätverk.",
|
||||
"save_error": "Kunde inte spara. Försök igen.",
|
||||
"qr_print": "Skriv ut",
|
||||
"qr_download": "Ladda ner SVG",
|
||||
"qr_action": "Åtgärd vid skanning",
|
||||
"qr_action_view": "Visa underhållsinformation",
|
||||
"qr_action_complete": "Markera underhåll som slutfört",
|
||||
"qr_url_mode": "Länktyp",
|
||||
"qr_mode_companion": "Companion App",
|
||||
"qr_mode_local": "Lokal (mDNS)",
|
||||
"qr_mode_server": "Server-URL",
|
||||
"overview": "Översikt",
|
||||
"analysis": "Analys",
|
||||
"recent_activities": "Senaste aktiviteter",
|
||||
"search_notes": "Sök i anteckningar",
|
||||
"avg_cost": "Snittlig kostnad",
|
||||
"no_advanced_features": "Inga avancerade funktioner aktiverade",
|
||||
"no_advanced_features_hint": "Aktivera „Adaptiva intervall” eller „Säsongsmönster” i integrationsinställningar för att se analysdata här.",
|
||||
"analysis_not_enough_data": "Inte tillräckligt med data för analys ännu.",
|
||||
"analysis_not_enough_data_hint": "Weibull-analys kräver minst 5 slutförda underhåll; säsongsmönster blir synliga efter 6+ datapunkter per månad.",
|
||||
"analysis_manual_task_hint": "Manuella uppgifter utan intervall genererar inte analysdata.",
|
||||
"completions": "slutföranden",
|
||||
"current": "Aktuell",
|
||||
"shorter": "Kortare",
|
||||
"longer": "Längre",
|
||||
"normal": "Normal",
|
||||
"disabled": "Inaktiverad",
|
||||
"compound_logic": "Sammansatt logik",
|
||||
"compound": "Sammansatt (flera villkor)",
|
||||
"compound_logic_and": "OCH — alla villkor måste utlösas",
|
||||
"compound_logic_or": "ELLER — ett villkor räcker",
|
||||
"compound_help": "Kombinera flera sensorvillkor till en utlösare.",
|
||||
"compound_no_conditions": "Inga villkor än — lägg till minst ett.",
|
||||
"compound_add_condition": "Lägg till villkor",
|
||||
"compound_condition": "Villkor",
|
||||
"compound_remove_condition": "Ta bort villkor",
|
||||
"card_title": "Titel",
|
||||
"card_show_header": "Visa rubrik med statistik",
|
||||
"card_show_actions": "Visa åtgärdsknappar",
|
||||
"card_compact": "Kompakt läge",
|
||||
"card_max_items": "Max objekt (0 = alla)",
|
||||
"card_filter_status": "Filtrera efter status",
|
||||
"card_filter_status_help": "Tomt = visa alla statusar.",
|
||||
"card_filter_objects": "Filtrera efter objekt",
|
||||
"card_filter_objects_help": "Tomt = visa alla objekt.",
|
||||
"card_filter_entities": "Filtrera efter entiteter (entity_ids)",
|
||||
"card_filter_entities_help": "Välj sensor- / binary_sensor-entiteter från denna integration. Tomt = alla.",
|
||||
"card_loading_objects": "Laddar objekt…",
|
||||
"card_load_error": "Kunde inte ladda objekt — kontrollera WebSocket-anslutningen.",
|
||||
"card_no_tasks_title": "Inga underhållsuppgifter än",
|
||||
"card_no_tasks_cta": "→ Skapa en i Maintenance-panelen",
|
||||
"no_objects": "Inga objekt än.",
|
||||
"action_error": "Åtgärden misslyckades. Försök igen.",
|
||||
"area_id_optional": "Område (valfritt)",
|
||||
"installation_date_optional": "Installationsdatum (valfritt)",
|
||||
"warranty_expiry_optional": "Garantins utgång (valfritt)",
|
||||
"warranty": "Garanti",
|
||||
"warranty_valid_until": "giltig till {date}",
|
||||
"warranty_expires_in": "går ut om {days} dagar",
|
||||
"warranty_expired": "utgången",
|
||||
"cal_past_windows": "Tidigare fönster",
|
||||
"cal_forward_windows": "Kommande fönster",
|
||||
"history_edit_title": "Redigera historikpost",
|
||||
"history_edit_timestamp": "Tidsstämpel",
|
||||
"manufacturer": "Tillverkare",
|
||||
"model": "Modell",
|
||||
"area": "Område",
|
||||
"actions": "Åtgärder",
|
||||
"view_mode_label": "Vy",
|
||||
"view_cards": "Kortvy",
|
||||
"view_table": "Tabellvy",
|
||||
"objects_table_columns_label": "Kolumner i objekttabell",
|
||||
"objects_table_columns_hint": "Välj vilka kolumner som visas i objekttabellvyn.",
|
||||
"custom_icon_optional": "Ikon (valfritt, t.ex. mdi:wrench)",
|
||||
"task_enabled": "Uppgift aktiverad",
|
||||
"skip_reason_prompt": "Hoppa över denna uppgift?",
|
||||
"reason_optional": "Anledning (valfritt)",
|
||||
"reset_date_prompt": "Markera uppgift som utförd?",
|
||||
"reset_date_optional": "Datum för senaste utförande (valfritt, standard idag)",
|
||||
"notes_label": "Anteckningar",
|
||||
"documentation_label": "Dokumentation",
|
||||
"no_nfc_tag": "— Ingen tagg —",
|
||||
"dashboard": "Översikt",
|
||||
"tab_today": "Idag",
|
||||
"palette_placeholder": "Sök objekt och uppgifter…",
|
||||
"palette_no_results": "Inga träffar",
|
||||
"palette_hint": "↑↓ navigera · Enter öppna · Esc stäng",
|
||||
"today_all_caught_up": "Allt klart! Inget den här veckan.",
|
||||
"today_overdue": "Försenade",
|
||||
"today_due_today": "Förfaller idag",
|
||||
"today_this_week": "Den här veckan",
|
||||
"settings": "Inställningar",
|
||||
"settings_features": "Avancerade funktioner",
|
||||
"settings_features_desc": "Aktivera eller inaktivera avancerade funktioner. Inaktivering döljer dem från UI men tar inte bort data.",
|
||||
"feat_adaptive": "Adaptiv schemaläggning",
|
||||
"feat_adaptive_desc": "Lär dig optimala intervall från underhållshistorik",
|
||||
"feat_predictions": "Sensorpredictions",
|
||||
"feat_predictions_desc": "Förutsäg utlösningsdatum från sensordegradering",
|
||||
"feat_seasonal": "Säsongsjusteringar",
|
||||
"feat_seasonal_desc": "Justera intervall baserat på säsongsmönster",
|
||||
"feat_environmental": "Miljökorrelation",
|
||||
"feat_environmental_desc": "Korrelera intervall med temperatur/luftfuktighet",
|
||||
"feat_budget": "Budgetuppföljning",
|
||||
"feat_budget_desc": "Spåra månatliga och årliga underhållsutgifter",
|
||||
"feat_groups": "Uppgiftsgrupper",
|
||||
"feat_groups_desc": "Organisera uppgifter i logiska grupper",
|
||||
"feat_checklists": "Checklistor",
|
||||
"feat_checklists_desc": "Flerstegs procedurer för uppgiftens slutförande",
|
||||
"settings_general": "Allmänt",
|
||||
"settings_default_warning": "Standard varningsdagar",
|
||||
"settings_panel_enabled": "Sidopanel",
|
||||
"settings_panel_title": "Sidopanelens titel",
|
||||
"settings_notifications": "Notifikationer",
|
||||
"settings_notify_service": "Notifikationstjänst",
|
||||
"test_notification": "Testnotifikation",
|
||||
"send_test": "Skicka test",
|
||||
"testing": "Skickar…",
|
||||
"test_notification_success": "Testnotifikation skickad",
|
||||
"test_notification_failed": "Testnotifikation misslyckades",
|
||||
"settings_notify_due_soon": "Notifiera när snart förfallande",
|
||||
"settings_notify_overdue": "Notifiera när försenad",
|
||||
"settings_notify_triggered": "Notifiera när utlöst",
|
||||
"settings_interval_hours": "Upprepningsintervall (timmar, 0 = en gång)",
|
||||
"settings_quiet_hours": "Tysta timmar",
|
||||
"settings_quiet_start": "Start",
|
||||
"settings_quiet_end": "Slut",
|
||||
"settings_max_per_day": "Max notifikationer per dag (0 = obegränsat)",
|
||||
"settings_bundling": "Bunta notifikationer",
|
||||
"settings_bundle_threshold": "Buntningströskel",
|
||||
"settings_reminder_leads": "Extra reminders (days before due)",
|
||||
"settings_reminder_leads_hint": "Comma-separated lead times, e.g. 14, 3, 0 — one extra reminder fires on each matching day. Empty = off.",
|
||||
"settings_actions": "Mobila åtgärdsknappar",
|
||||
"settings_action_complete": "Visa 'Slutför'-knapp",
|
||||
"settings_action_skip": "Visa 'Hoppa över'-knapp",
|
||||
"settings_action_snooze": "Visa 'Snooza'-knapp",
|
||||
"settings_weekly_digest": "Veckosammanfattning",
|
||||
"settings_weekly_digest_hint": "En sammanfattningsavisering måndag morgon när uppgifter förfaller.",
|
||||
"settings_warranty_reminder": "Warranty expiry reminder",
|
||||
"settings_warranty_reminder_days": "Days before expiry",
|
||||
"settings_warranty_reminder_hint": "Notify once when an object's warranty is this many days from expiring.",
|
||||
"settings_snooze_hours": "Snooza-tid (timmar)",
|
||||
"settings_budget": "Budget",
|
||||
"settings_currency": "Valuta",
|
||||
"settings_budget_monthly": "Månatlig budget",
|
||||
"settings_budget_yearly": "Årlig budget",
|
||||
"settings_budget_alerts": "Budgetvarningar",
|
||||
"settings_budget_threshold": "Varningströskel (%)",
|
||||
"settings_import_export": "Import / Export",
|
||||
"settings_export_json": "Exportera JSON",
|
||||
"settings_export_yaml": "Exportera YAML",
|
||||
"settings_export_csv": "Exportera CSV",
|
||||
"settings_import_csv": "Importera CSV",
|
||||
"settings_import_placeholder": "Klistra in JSON- eller CSV-innehåll här…",
|
||||
"settings_import_btn": "Importera",
|
||||
"settings_import_success": "{count} objekt importerade.",
|
||||
"settings_export_success": "Export nedladdad.",
|
||||
"settings_saved": "Inställning sparad.",
|
||||
"settings_include_history": "Inkludera historik",
|
||||
"sort_alphabetical": "Alfabetisk",
|
||||
"sort_due_soonest": "Närmaste förfallodatum",
|
||||
"sort_task_count": "Antal uppgifter",
|
||||
"sort_area": "Område",
|
||||
"sort_assigned_user": "Tilldelad användare",
|
||||
"sort_group": "Grupp",
|
||||
"groupby_none": "Ingen gruppering",
|
||||
"groupby_area": "Per område",
|
||||
"groupby_group": "Per grupp",
|
||||
"groupby_user": "Per användare",
|
||||
"filter_label": "Filter",
|
||||
"user_label": "Användare",
|
||||
"sort_label": "Sortering",
|
||||
"group_by_label": "Gruppera efter",
|
||||
"state_value_help": "Använd HA-tillståndsvärdet (vanligtvis med små bokstäver, t.ex. \"on\"/\"off\"). Versaler normaliseras vid sparande.",
|
||||
"target_changes_help": "Antal matchande övergångar innan utlösaren aktiveras (standard: 1).",
|
||||
"qr_print_title": "Skriv ut QR-koder",
|
||||
"qr_print_desc": "Skapa en utskriftssida med QR-koder att klippa ut och sätta på din utrustning.",
|
||||
"qr_print_load": "Ladda objekt",
|
||||
"qr_print_filter": "Filter",
|
||||
"qr_print_objects": "Objekt",
|
||||
"qr_print_actions": "Åtgärder",
|
||||
"qr_print_url_mode": "Länktyp",
|
||||
"qr_print_estimate": "Uppskattade QR-koder",
|
||||
"qr_print_over_limit": "gräns 200, begränsa filtret",
|
||||
"qr_print_generate": "Skapa QR-koder",
|
||||
"qr_print_generating": "Skapar…",
|
||||
"qr_print_ready": "QR-koder klara",
|
||||
"qr_print_print_button": "Skriv ut",
|
||||
"qr_print_empty": "Inget att skapa",
|
||||
"qr_action_skip": "Hoppa över",
|
||||
"vacation_title": "Semesterläge",
|
||||
"vacation_active": "aktivt",
|
||||
"vacation_ended": "avslutat",
|
||||
"vacation_desc": "Planera din semester: aviseringar pausas under perioden plus några buffert-dagar. Du kan göra undantag per uppgift.",
|
||||
"vacation_enable": "Aktivera semesterläge",
|
||||
"vacation_start": "Start",
|
||||
"vacation_end": "Slut",
|
||||
"vacation_buffer": "Buffert (dagar)",
|
||||
"vacation_exempt_title": "Avisera ändå under semester",
|
||||
"vacation_exempt_desc": "Välj uppgifter som ska avisera även under semestern (t.ex. kritisk poolkemi).",
|
||||
"vacation_load_tasks": "Ladda uppgifter",
|
||||
"vacation_preview_btn": "Visa förhandsgranskning",
|
||||
"vacation_preview_affected": "uppgifter berörda",
|
||||
"vacation_event_due_soon": "blir snart förfallen",
|
||||
"vacation_event_overdue": "blir försenad",
|
||||
"vacation_event_triggered_est": "möjlig sensorutlösning",
|
||||
"vacation_sensor_based": "(sensorbaserad)",
|
||||
"vacation_action_notify": "Avisera ändå",
|
||||
"vacation_action_unsilence": "Tysta igen",
|
||||
"vacation_marked_complete": "Markerad som klar",
|
||||
"vacation_marked_skip": "Hoppade över",
|
||||
"vacation_end_now": "Avsluta semester nu",
|
||||
"unassigned": "Otilldelad",
|
||||
"no_area": "Inget område",
|
||||
"has_overdue": "Har försenade uppgifter",
|
||||
"object": "Objekt",
|
||||
"settings_panel_access": "Paneltillgång",
|
||||
"settings_panel_access_desc": "Administratörer har alltid full åtkomst. För att delegera skapa, redigera och ta bort till specifika icke-admin-användare, slå på detta och välj dem nedan — alla andra ser endast Slutför och Hoppa över.",
|
||||
"settings_operator_write": "Tillåt valda användare att skapa, redigera och ta bort",
|
||||
"settings_operator_write_desc": "Av: endast administratörer kan ändra innehåll. På: de valda användarna nedan får också full åtkomst.",
|
||||
"no_non_admin_users": "Inga icke-admin-användare hittades. Lägg till några i Inställningar → Personer.",
|
||||
"owner_label": "Ägare",
|
||||
"feat_completion_actions": "Slutförande-åtgärder",
|
||||
"feat_completion_actions_desc": "HA-åtgärd per uppgift vid slutförande + snabb-slutför-QR med förinställda värden.",
|
||||
"on_complete_action_title": "Vid slutförande: utlös HA-åtgärd (valfritt)",
|
||||
"on_complete_action_desc": "Anropar en HA-tjänst när uppgiften slutförs — t.ex. återställ en räknare på enheten.",
|
||||
"on_complete_action_service": "Tjänst",
|
||||
"on_complete_action_target": "Målentitet",
|
||||
"on_complete_action_data": "Data (JSON, valfritt)",
|
||||
"on_complete_action_test": "Testa åtgärd",
|
||||
"on_complete_action_test_success": "Lyckades",
|
||||
"on_complete_action_test_failed": "Misslyckades",
|
||||
"quick_complete_defaults_title": "Snabb-slutför standardvärden (för QR-skanningar, valfritt)",
|
||||
"quick_complete_defaults_desc": "Förinställda värden för snabb-slutför-QR. Utan dessa öppnar QR slutför-dialogen.",
|
||||
"quick_complete_defaults_notes": "Anteckningar",
|
||||
"quick_complete_defaults_cost": "Kostnad",
|
||||
"quick_complete_defaults_duration": "Varaktighet (minuter)",
|
||||
"quick_complete_defaults_feedback_none": "Ingen feedback",
|
||||
"quick_complete_defaults_feedback_needed": "Var nödvändigt",
|
||||
"quick_complete_defaults_feedback_not_needed": "Inte nödvändigt",
|
||||
"quick_complete_success": "Snabbt markerad som klar",
|
||||
"trigger_replaced": "Utlösare ersatt",
|
||||
"add": "Lägg till",
|
||||
"show_stats": "Visa statistik + grafer",
|
||||
"hide_stats": "Dölj statistik",
|
||||
"adaptive_no_data": "Ännu inte tillräckligt med slutförandehistorik för adaptiv analys. Slutför denna uppgift några gånger till för att låsa upp intervallrekommendationer och tillförlitlighetsdiagram.",
|
||||
"suggestion_applied": "Föreslaget intervall tillämpat",
|
||||
"vacation_mode": "Semesterläge",
|
||||
"vacation_status_active": "Aktivt nu",
|
||||
"vacation_status_scheduled": "Schemalagt",
|
||||
"vacation_status_inactive": "Inaktivt",
|
||||
"vacation_end_now_confirm": "Avsluta semestern omedelbart?",
|
||||
"vacation_exempt_count": "undantagna",
|
||||
"vacation_advanced": "Avancerat…",
|
||||
"vacation_open_panel": "Öppna i panelen",
|
||||
"enable": "Aktivera",
|
||||
"saved": "Sparat",
|
||||
"budget_monthly_set": "Ange månad",
|
||||
"budget_yearly_set": "Ange år",
|
||||
"budget_advanced": "Valuta, varningar…",
|
||||
"budget_open_panel": "Öppna i panelen",
|
||||
"groups_empty": "Inga grupper än.",
|
||||
"group_new_placeholder": "Lägg till grupp…",
|
||||
"group_delete_confirm": "Ta bort gruppen ”{name}”?",
|
||||
"groups_manage_tasks": "Hantera uppgiftstilldelningar…",
|
||||
"groups_open_panel": "Öppna i panelen",
|
||||
"on_complete_action_target_hint": "Obs: entitetens domän måste matcha tjänsten — t.ex. 'button.press' fungerar bara på button.*, 'counter.increment' bara på counter.*, 'input_button.press' bara på input_button.* osv. Vid en avvikelse misslyckas åtgärden tyst (HA loggar 'Referenced entities ... missing or not currently available').",
|
||||
"show_all_objects": "Visa alla objekt",
|
||||
"show_all_tasks": "Rensa filter — visa alla uppgifter",
|
||||
"filter_to_overdue": "Filtrera listan till endast försenade",
|
||||
"filter_to_due_soon": "Filtrera listan till endast snart förfallna",
|
||||
"filter_to_triggered": "Filtrera listan till endast utlösta",
|
||||
"open_task": "Öppna uppgift",
|
||||
"show_details": "Visa historik + statistik",
|
||||
"hide_details": "Dölj detaljer",
|
||||
"history_empty": "Ingen historik än.",
|
||||
"history_edit_button": "Redigera post",
|
||||
"total_cost": "Total kostnad",
|
||||
"times_performed": "Utförd",
|
||||
"older_entries": "äldre",
|
||||
"open_in_panel": "Öppna i Underhållspanelen",
|
||||
"skip_reason": "Anledning till överhoppning (valfritt)",
|
||||
"reset_to_date": "Återställ senast utförd till",
|
||||
"delete_task_confirm": "Ta bort denna uppgift och dess historik?",
|
||||
"delete_object_confirm": "Ta bort detta objekt och alla dess uppgifter?",
|
||||
"loading": "Laddar…",
|
||||
"archive": "Arkivera",
|
||||
"undo": "Ångra",
|
||||
"task_archived": "Uppgift arkiverad",
|
||||
"object_archived": "Objekt arkiverat",
|
||||
"unarchive": "Återställ",
|
||||
"archived": "Arkiverad",
|
||||
"show_archived": "Visa arkiverade",
|
||||
"hide_archived": "Dölj arkiverade",
|
||||
"confirm_archive_object": "Arkivera detta objekt och dess uppgifter? Historiken behålls och kan återställas senare.",
|
||||
"settings_archive": "Arkiv och lagring",
|
||||
"settings_archive_desc": "Lägg undan slutförda engångsuppgifter utan att radera dem. Arkiverade objekt döljs och är inaktiva men behåller historik och kostnad.",
|
||||
"settings_archive_oneoff_days": "Arkivera slutförda engångsuppgifter automatiskt efter (dagar, 0 = av)",
|
||||
"settings_delete_archived_oneoff_days": "Radera arkiverade engångsuppgifter automatiskt efter (dagar, 0 = aldrig)",
|
||||
"archive_object": "Arkivera objekt",
|
||||
"unarchive_object": "Återställ objekt",
|
||||
"documents": "Dokument",
|
||||
"documents_empty": "Inga dokument ännu.",
|
||||
"doc_upload": "Ladda upp fil",
|
||||
"doc_uploading": "Laddar upp…",
|
||||
"doc_add_link": "Lägg till länk",
|
||||
"doc_link_url": "URL (https://…)",
|
||||
"doc_link_title": "Titel (valfritt)",
|
||||
"doc_open": "Öppna",
|
||||
"doc_delete_confirm": "Ta bort \"{name}\"?",
|
||||
"doc_too_large": "Filen är för stor (max 25 MB).",
|
||||
"doc_upload_failed": "Uppladdningen misslyckades.",
|
||||
"completion_photo_optional": "Completion photo (optional)",
|
||||
"add_photo": "Add photo",
|
||||
"uploading": "Uploading…",
|
||||
"remove": "Remove",
|
||||
"doc_deduped": "Redan lagrad någon annanstans — delad, inget extra utrymme.",
|
||||
"doc_dup_in_object": "Den här filen är redan kopplad till detta objekt.",
|
||||
"doc_link_invalid": "Endast http/https-länkar tillåts.",
|
||||
"doc_cat_manual": "Manual",
|
||||
"doc_cat_warranty": "Garanti",
|
||||
"doc_cat_invoice": "Faktura",
|
||||
"doc_cat_spare_parts": "Reservdelar",
|
||||
"doc_cat_photo": "Foto",
|
||||
"doc_cat_other": "Övrigt",
|
||||
"doc_link_badge": "Länk",
|
||||
"doc_storage_title": "Dokumentlagring",
|
||||
"doc_storage_saved": "Sparat genom deduplicering",
|
||||
"doc_storage_refresh": "Uppdatera",
|
||||
"doc_download": "Ladda ner",
|
||||
"doc_close": "Stäng",
|
||||
"doc_camera": "Ta foto",
|
||||
"doc_drop_hint": "Släpp filer här",
|
||||
"doc_task_none": "Inga dokument kopplade till denna uppgift.",
|
||||
"doc_link_existing": "Koppla ett dokument…",
|
||||
"doc_attach": "Koppla",
|
||||
"doc_unlink": "Koppla bort",
|
||||
"doc_page": "Sida",
|
||||
"chart_range_7d": "7d",
|
||||
"chart_range_30d": "30d",
|
||||
"chart_range_90d": "90d",
|
||||
"chart_range_1y": "1å",
|
||||
"chart_since_service": "sedan senaste underhåll",
|
||||
"chart_no_stats": "Ingen långtidsstatistik för denna entitet — visar endast värden från underhållsposter",
|
||||
"auto_complete_on_recovery": "Slutför automatiskt när sensorn återhämtar sig",
|
||||
"auto_complete_on_recovery_help": "Registrerar ett slutförande (sätter senast utförd) när utlösaren löser sig själv — t.ex. salt påfyllt, filter bytt.",
|
||||
"doc_search": "Sök dokument…",
|
||||
"doc_search_none": "Inga matchande dokument",
|
||||
"link_device_optional": "Länka till befintlig enhet (valfritt)",
|
||||
"parent_object_optional": "Överordnat objekt (valfritt)",
|
||||
"parent_none": "(Inget överordnat)",
|
||||
"paused": "Pausad",
|
||||
"pause_object": "Pausa",
|
||||
"resume_object": "Återuppta",
|
||||
"pause_until_prompt": "Frys objektets scheman — inget förfaller och inget aviserar förrän det återupptas. Ange valfritt ett datum för automatisk återupptagning.",
|
||||
"pause_until_label": "Återuppta den (valfritt)",
|
||||
"object_paused": "Objekt pausat",
|
||||
"object_resumed": "Objekt återupptaget — scheman omstartade",
|
||||
"object_paused_badge": "Pausad",
|
||||
"paused_until_label": "till",
|
||||
"replace_object": "Ersätt…",
|
||||
"replace_object_prompt": "Pensionera detta objekt och skapa en efterträdare. Historik och kostnader arkiveras på det gamla; uppgifter och dokument följer med till det nya, räknare börjar om.",
|
||||
"replace_name_label": "Efterträdarens namn",
|
||||
"object_replaced": "Objekt ersatt — efterträdare skapad",
|
||||
"reading_unit_label": "Avläsningsenhet (t.ex. kWh, m³)",
|
||||
"reading_unit_help": "Visas bredvid det registrerade värdet när uppgiften slutförs.",
|
||||
"reading_value_label": "Avläst värde",
|
||||
"reading_label": "Avläsning",
|
||||
"settings_templates_label": "Mallgalleri",
|
||||
"settings_templates_hint": "Avmarkera mallar du aldrig behöver — de försvinner från \"Från mall\"-väljarna (panel och config flow). Inget annat ändras; kan återaktiveras när som helst.",
|
||||
"worksheet": "Arbetsblad",
|
||||
"worksheet_scan_view": "Skanna för att öppna uppgiften",
|
||||
"worksheet_scan_complete": "Skanna för att slutföra",
|
||||
"worksheet_manual_excerpt": "Utdrag ur manualen",
|
||||
"worksheet_pages": "sidor",
|
||||
"worksheet_printed": "Utskriven",
|
||||
"worksheet_never": "Aldrig"
|
||||
}
|
||||
@@ -0,0 +1,696 @@
|
||||
{
|
||||
"maintenance": "Обслуговування",
|
||||
"objects": "Об'єкти",
|
||||
"tasks": "Завдання",
|
||||
"overdue": "Прострочено",
|
||||
"due_soon": "Незабаром",
|
||||
"triggered": "Спрацювало",
|
||||
"ok": "Норма",
|
||||
"all": "Всі",
|
||||
"new_object": "+ Новий об'єкт",
|
||||
"templates_from": "Із шаблону",
|
||||
"templates_title": "Почати із шаблону",
|
||||
"templates_task_count": "{n} завдань",
|
||||
"template_created": "Створено із шаблону",
|
||||
"onboard_hint": "Додайте перший об'єкт, щоб відстежувати обслуговування.",
|
||||
"edit": "Редагувати",
|
||||
"duplicate": "Дублювати",
|
||||
"task_duplicated": "Завдання продубльовано",
|
||||
"object_duplicated": "Об'єкт продубльовано",
|
||||
"delete": "Видалити",
|
||||
"add_task": "+ Додати завдання",
|
||||
"complete": "Виконати",
|
||||
"completed": "Виконано",
|
||||
"skip": "Пропустити",
|
||||
"skipped": "Пропущено",
|
||||
"missed": "Missed",
|
||||
"reset": "Скинути",
|
||||
"snooze": "Snooze",
|
||||
"snoozed": "Snoozed",
|
||||
"cancel": "Скасувати",
|
||||
"bulk_select": "Вибрати",
|
||||
"bulk_select_all": "Вибрати все",
|
||||
"bulk_n_selected": "Вибрано: {n}",
|
||||
"bulk_completed": "Виконано завдань: {n}",
|
||||
"bulk_archived": "Архівовано завдань: {n}",
|
||||
"completing": "Виконується…",
|
||||
"interval": "Інтервал",
|
||||
"warning": "Попередження",
|
||||
"last_performed": "Останнє виконання",
|
||||
"next_due": "Наступний термін",
|
||||
"days_until_due": "Днів до терміну",
|
||||
"avg_duration": "Сер. тривалість",
|
||||
"trigger": "Тригер",
|
||||
"trigger_type": "Тип тригера",
|
||||
"threshold_above": "Верхня межа",
|
||||
"threshold_below": "Нижня межа",
|
||||
"threshold": "Поріг",
|
||||
"counter": "Лічильник",
|
||||
"state_change": "Зміна стану",
|
||||
"runtime": "Напрацювання",
|
||||
"runtime_hours": "Цільове напрацювання (години)",
|
||||
"target_value": "Цільове значення",
|
||||
"baseline": "Базове значення",
|
||||
"target_changes": "Цільова кількість змін",
|
||||
"for_minutes": "Протягом (хвилин)",
|
||||
"time_based": "За часом",
|
||||
"sensor_based": "За сенсором",
|
||||
"manual": "Вручну",
|
||||
"one_time": "Одноразово",
|
||||
"weekdays": "Дні тижня",
|
||||
"nth_weekday": "N-й день тижня в місяці",
|
||||
"day_of_month": "День місяця",
|
||||
"recurrence_on_days": "Повторювати у",
|
||||
"recurrence_occurrence": "Входження",
|
||||
"recurrence_weekday": "День тижня",
|
||||
"recurrence_day": "День місяця (1–31)",
|
||||
"recurrence_last_day": "Last day of the month",
|
||||
"recurrence_business_day": "Business days only (roll back from weekend)",
|
||||
"recurrence_offset": "Offset (days, ±)",
|
||||
"recurrence_offset_help": "Shift the date by ±N days, e.g. -2 = two days before.",
|
||||
"last_day_month": "Last day of month",
|
||||
"last_business_day_month": "Last business day",
|
||||
"ord_1": "1-й",
|
||||
"ord_2": "2-й",
|
||||
"ord_3": "3-й",
|
||||
"ord_4": "4-й",
|
||||
"ord_5": "5-й",
|
||||
"ord_last": "Останній",
|
||||
"day_word": "День",
|
||||
"interval_value": "Інтервал",
|
||||
"interval_unit": "Одиниця",
|
||||
"unit_days": "Дні",
|
||||
"unit_weeks": "Тижні",
|
||||
"unit_months": "Місяці",
|
||||
"unit_years": "Роки",
|
||||
"due_date": "Дата виконання",
|
||||
"cleaning": "Очищення",
|
||||
"inspection": "Огляд",
|
||||
"replacement": "Заміна",
|
||||
"calibration": "Калібрування",
|
||||
"service": "Сервіс",
|
||||
"reading": "Показання",
|
||||
"custom": "Власний",
|
||||
"history": "Історія",
|
||||
"cost": "Вартість",
|
||||
"report_button": "Звіт",
|
||||
"report_title": "Звіт про обслуговування",
|
||||
"report_generated": "Створено",
|
||||
"report_times_done": "Виконано",
|
||||
"report_total_cost": "Загальна вартість",
|
||||
"report_every": "кожні {n} {unit}",
|
||||
"report_notes": "Нотатки",
|
||||
"report_col_type": "Тип",
|
||||
"report_col_status": "Статус",
|
||||
"report_col_schedule": "Розклад",
|
||||
"duration": "Тривалість",
|
||||
"both": "Обидва",
|
||||
"trigger_val": "Значення тригера",
|
||||
"complete_title": "Виконати: ",
|
||||
"checklist": "Чекліст",
|
||||
"checklist_steps_optional": "Кроки чекліста (необов'язково)",
|
||||
"checklist_placeholder": "Очистити фільтр\nЗамінити ущільнювач\nПеревірити тиск",
|
||||
"checklist_help": "Один крок на рядок. Макс. 100 елементів.",
|
||||
"err_too_long": "{field}: задовге (макс. {n} символів)",
|
||||
"err_too_short": "{field}: закоротке (мін. {n} символів)",
|
||||
"err_value_too_high": "{field}: завелике (макс. {n})",
|
||||
"err_value_too_low": "{field}: замале (мін. {n})",
|
||||
"err_required": "{field}: обов'язкове поле",
|
||||
"err_wrong_type": "{field}: невірний тип (очікувалось: {type})",
|
||||
"err_invalid_choice": "{field}: недопустиме значення",
|
||||
"err_invalid_value": "{field}: невірне значення",
|
||||
"feat_schedule_time": "Планування за часом доби",
|
||||
"feat_schedule_time_desc": "Задачі стають простроченими у певний час доби, а не опівночі.",
|
||||
"schedule_time_optional": "Час прострочення (необов'язково, HH:MM)",
|
||||
"schedule_time_help": "Порожньо = опівночі (за замовчуванням). Часовий пояс HA.",
|
||||
"at_time": "о",
|
||||
"notes_optional": "Примітки (необов'язково)",
|
||||
"cost_optional": "Вартість (необов'язково)",
|
||||
"duration_minutes": "Тривалість у хвилинах (необов'язково)",
|
||||
"days": "днів",
|
||||
"day": "день",
|
||||
"today": "Сьогодні",
|
||||
"d_overdue": "д прострочено",
|
||||
"no_tasks": "Завдань обслуговування ще немає. Створіть об'єкт, щоб почати.",
|
||||
"no_tasks_short": "Немає завдань",
|
||||
"no_history": "Записів в історії ще немає.",
|
||||
"show_all": "Показати всі",
|
||||
"cost_duration_chart": "Вартість і тривалість",
|
||||
"installed": "Встановлено",
|
||||
"confirm_delete_object": "Видалити цей об'єкт і всі його завдання?",
|
||||
"confirm_delete_task": "Видалити це завдання?",
|
||||
"min": "Мін",
|
||||
"max": "Макс",
|
||||
"save": "Зберегти",
|
||||
"saving": "Збереження…",
|
||||
"edit_task": "Редагувати завдання",
|
||||
"new_task": "Нове завдання обслуговування",
|
||||
"task_name": "Назва завдання",
|
||||
"maintenance_type": "Тип обслуговування",
|
||||
"priority": "Пріоритет",
|
||||
"labels": "Labels",
|
||||
"labels_placeholder": "e.g. safety, seasonal, tenant-visible",
|
||||
"labels_help": "Comma-separated tags for filtering and reporting.",
|
||||
"priority_low": "Низький",
|
||||
"priority_normal": "Звичайний",
|
||||
"priority_high": "Високий",
|
||||
"schedule_type": "Тип розкладу",
|
||||
"interval_days": "Інтервал (дні)",
|
||||
"warning_days": "Днів попередження",
|
||||
"earliest_completion_days": "Earliest completion (days before due)",
|
||||
"earliest_completion_days_help": "Leave empty to allow completing any time. 0 = only on/after the due date.",
|
||||
"interval_anchor": "Прив'язка інтервалу",
|
||||
"anchor_completion": "Від дати виконання",
|
||||
"anchor_planned": "Від запланованої дати (без зміщення)",
|
||||
"edit_object": "Редагувати об'єкт",
|
||||
"name": "Назва",
|
||||
"manufacturer_optional": "Виробник (необов'язково)",
|
||||
"model_optional": "Модель (необов'язково)",
|
||||
"serial_number_optional": "Серійний номер (необов'язково)",
|
||||
"serial_number_label": "С/Н",
|
||||
"documentation_url_label": "Посібник",
|
||||
"object_notes_label": "Примітки",
|
||||
"last_performed_optional": "Останнé виконання (необов'язково)",
|
||||
"sort_due_date": "Дата терміну",
|
||||
"sort_object": "Назва об'єкта",
|
||||
"sort_type": "Тип",
|
||||
"sort_task_name": "Назва завдання",
|
||||
"all_objects": "Всі об'єкти",
|
||||
"tasks_lower": "завдань",
|
||||
"no_tasks_yet": "Завдань ще немає",
|
||||
"add_first_task": "Додати перше завдання",
|
||||
"trigger_configuration": "Налаштування тригера",
|
||||
"entity_id": "ID об'єкта",
|
||||
"comma_separated": "через кому",
|
||||
"entity_logic": "Логіка об'єктів",
|
||||
"entity_logic_any": "Будь-який об'єкт спрацьовує",
|
||||
"entity_logic_all": "Всі об'єкти мають спрацювати",
|
||||
"entities": "об'єктів",
|
||||
"attribute_optional": "Атрибут (необов'язково, порожньо = стан)",
|
||||
"use_entity_state": "Використовувати стан об'єкта (без атрибута)",
|
||||
"trigger_above": "Спрацювати, коли вище",
|
||||
"trigger_below": "Спрацювати, коли нижче",
|
||||
"for_at_least_minutes": "Протягом не менше (хвилин)",
|
||||
"safety_interval_days": "Страховий інтервал (дні, необов'язково)",
|
||||
"safety_interval": "Страховий інтервал (необов'язково)",
|
||||
"delta_mode": "Режим дельти",
|
||||
"from_state_optional": "З стану (необов'язково)",
|
||||
"to_state_optional": "До стану (необов'язково)",
|
||||
"documentation_url_optional": "URL документації (необов'язково)",
|
||||
"object_notes_optional": "Примітки (необов'язково)",
|
||||
"nfc_tag_id_optional": "ID NFC-тега (необов'язково)",
|
||||
"nfc_tags_empty_help": "У Home Assistant ще не зареєстровано NFC-теги.",
|
||||
"nfc_tags_open_settings": "Відкрити налаштування тегів",
|
||||
"nfc_tags_refresh": "Оновити",
|
||||
"environmental_entity_optional": "Датчик навколишнього середовища (необов'язково)",
|
||||
"environmental_entity_helper": "напр. sensor.outdoor_temperature — коригує інтервал відповідно до умов навколишнього середовища",
|
||||
"environmental_attribute_optional": "Атрибут середовища (необов'язково)",
|
||||
"nfc_tag_id": "ID NFC-тега",
|
||||
"nfc_linked": "NFC-тег прив'язано",
|
||||
"nfc_link_hint": "Натисніть, щоб прив'язати NFC-тег",
|
||||
"responsible_user": "Відповідальний користувач",
|
||||
"shared_with": "Shared with (rotation)",
|
||||
"shared_with_help": "Pick multiple people to share this task; the responsible person rotates on each completion.",
|
||||
"rotation_strategy": "Rotation",
|
||||
"rotation_none": "No rotation",
|
||||
"rotation_round_robin": "Round-robin",
|
||||
"rotation_least_completed": "Least completed",
|
||||
"rotation_random": "Random",
|
||||
"no_user_assigned": "(Користувача не призначено)",
|
||||
"all_users": "Всі користувачі",
|
||||
"my_tasks": "Мої завдання",
|
||||
"tab_calendar": "Календар",
|
||||
"cal_no_events": "Без обслуговування",
|
||||
"cal_window_7": "7 днів",
|
||||
"cal_window_14": "14 днів",
|
||||
"cal_window_30": "30 днів",
|
||||
"cal_window_365": "1 рік",
|
||||
"cal_every_n_days": "кожні {n} днів",
|
||||
"cal_source_time": "За часом",
|
||||
"cal_source_time_adaptive": "За часом (адаптивно)",
|
||||
"cal_source_sensor": "За датчиком",
|
||||
"cal_predicted": "прогноз",
|
||||
"cal_confidence_high": "висока надійність",
|
||||
"cal_confidence_medium": "середня надійність",
|
||||
"cal_confidence_low": "низька надійність",
|
||||
"budget_monthly": "Щомісячний бюджет",
|
||||
"budget_yearly": "Щорічний бюджет",
|
||||
"groups": "Групи",
|
||||
"new_group": "Нова група",
|
||||
"edit_group": "Редагувати групу",
|
||||
"no_groups": "Груп ще немає",
|
||||
"delete_group": "Видалити групу",
|
||||
"delete_group_confirm": "Видалити групу '{name}'?",
|
||||
"group_select_tasks": "Обрати завдання",
|
||||
"group_name_required": "Потрібна назва",
|
||||
"description_optional": "Опис (необов'язково)",
|
||||
"selected": "Обрано",
|
||||
"loading_chart": "Завантаження даних графіка...",
|
||||
"hide_outliers": "Приховати викиди (збої датчика)",
|
||||
"was_maintenance_needed": "Чи було потрібне це обслуговування?",
|
||||
"feedback_needed": "Потрібне",
|
||||
"feedback_not_needed": "Не потрібне",
|
||||
"feedback_not_sure": "Не впевнений",
|
||||
"suggested_interval": "Рекомендований інтервал",
|
||||
"apply_suggestion": "Застосувати",
|
||||
"reanalyze": "Повторно проаналізувати",
|
||||
"reanalyze_result": "Новий аналіз",
|
||||
"reanalyze_insufficient_data": "Недостатньо даних для рекомендації",
|
||||
"data_points": "точок даних",
|
||||
"dismiss_suggestion": "Відхилити",
|
||||
"confidence_low": "Низька",
|
||||
"confidence_medium": "Середня",
|
||||
"confidence_high": "Висока",
|
||||
"recommended": "рекомендовано",
|
||||
"seasonal_awareness": "Сезонна корекція",
|
||||
"edit_seasonal_overrides": "Редагувати сезонні коефіцієнти",
|
||||
"seasonal_overrides_title": "Сезонні коефіцієнти (перевизначення)",
|
||||
"seasonal_overrides_hint": "Коефіцієнт на місяць (0.1–5.0). Порожньо = автоматично.",
|
||||
"seasonal_override_invalid": "Недійсне значення",
|
||||
"seasonal_override_range": "Коефіцієнт має бути між 0.1 та 5.0",
|
||||
"clear_all": "Очистити все",
|
||||
"seasonal_chart_title": "Сезонні коефіцієнти",
|
||||
"seasonal_learned": "Навчена",
|
||||
"seasonal_manual": "Ручна",
|
||||
"month_jan": "Січ",
|
||||
"month_feb": "Лют",
|
||||
"month_mar": "Бер",
|
||||
"month_apr": "Кві",
|
||||
"month_may": "Тра",
|
||||
"month_jun": "Чер",
|
||||
"month_jul": "Лип",
|
||||
"month_aug": "Сер",
|
||||
"month_sep": "Вер",
|
||||
"month_oct": "Жов",
|
||||
"month_nov": "Лис",
|
||||
"month_dec": "Гру",
|
||||
"sensor_prediction": "Прогноз сенсора",
|
||||
"degradation_trend": "Тренд",
|
||||
"trend_rising": "Зростає",
|
||||
"trend_falling": "Спадає",
|
||||
"trend_stable": "Стабільний",
|
||||
"trend_insufficient_data": "Недостатньо даних",
|
||||
"days_until_threshold": "Днів до порогу",
|
||||
"threshold_exceeded": "Поріг перевищено",
|
||||
"environmental_adjustment": "Екологічний коефіцієнт",
|
||||
"sensor_prediction_urgency": "Сенсор прогнозує досягнення порогу через ~{days} днів",
|
||||
"day_short": "день",
|
||||
"weibull_reliability_curve": "Крива надійності",
|
||||
"weibull_failure_probability": "Ймовірність відмови",
|
||||
"weibull_r_squared": "Точність R²",
|
||||
"beta_early_failures": "Ранні відмови",
|
||||
"beta_random_failures": "Випадкові відмови",
|
||||
"beta_wear_out": "Знос",
|
||||
"beta_highly_predictable": "Дуже передбачуваний",
|
||||
"confidence_interval": "Довірчий інтервал",
|
||||
"confidence_conservative": "Консервативний",
|
||||
"confidence_aggressive": "Оптимістичний",
|
||||
"current_interval_marker": "Поточний інтервал",
|
||||
"recommended_marker": "Рекомендовано",
|
||||
"characteristic_life": "Характеристичний ресурс",
|
||||
"chart_mini_sparkline": "Мінімальний графік тренду",
|
||||
"chart_history": "Історія вартості та тривалості",
|
||||
"chart_seasonal": "Сезонні коефіцієнти, 12 місяців",
|
||||
"chart_weibull": "Крива надійності Вейбулла",
|
||||
"chart_sparkline": "Графік значень тригера сенсора",
|
||||
"days_progress": "Прогрес днів",
|
||||
"qr_code": "QR-код",
|
||||
"qr_generating": "Генерація QR-коду…",
|
||||
"qr_error": "Не вдалося згенерувати QR-код.",
|
||||
"qr_error_no_url": "URL Home Assistant не налаштовано. Задайте зовнішню або внутрішню URL-адресу в Налаштування → Система → Мережа.",
|
||||
"save_error": "Не вдалося зберегти. Спробуйте ще раз.",
|
||||
"qr_print": "Друкувати",
|
||||
"qr_download": "Завантажити SVG",
|
||||
"qr_action": "Дія при скануванні",
|
||||
"qr_action_view": "Переглянути",
|
||||
"qr_action_complete": "Позначити обслуговування виконаним",
|
||||
"qr_url_mode": "Тип посилання",
|
||||
"qr_mode_companion": "Companion App",
|
||||
"qr_mode_local": "Локальний (mDNS)",
|
||||
"qr_mode_server": "URL сервера",
|
||||
"overview": "Огляд",
|
||||
"analysis": "Аналіз",
|
||||
"recent_activities": "Остання активність",
|
||||
"search_notes": "Пошук у примітках",
|
||||
"avg_cost": "Сер. вартість",
|
||||
"no_advanced_features": "Розширені функції не увімкнено",
|
||||
"no_advanced_features_hint": "Увімкніть «Адаптивні інтервали» або «Сезонні закономірності» в налаштуваннях інтеграції, щоб побачити тут дані аналізу.",
|
||||
"analysis_not_enough_data": "Недостатньо даних для аналізу.",
|
||||
"analysis_not_enough_data_hint": "Аналіз Вейбулла потребує щонайменше 5 виконаних обслуговувань; сезонні закономірності стають видимими після 6+ записів на місяць.",
|
||||
"analysis_manual_task_hint": "Ручні завдання без інтервалу не генерують дані аналізу.",
|
||||
"completions": "виконань",
|
||||
"current": "Поточний",
|
||||
"shorter": "Коротший",
|
||||
"longer": "Довший",
|
||||
"normal": "Звичайний",
|
||||
"disabled": "Вимкнено",
|
||||
"compound_logic": "Складена логіка",
|
||||
"compound": "Складений (кілька умов)",
|
||||
"compound_logic_and": "І — усі умови мають спрацювати",
|
||||
"compound_logic_or": "АБО — достатньо будь-якої умови",
|
||||
"compound_help": "Об'єднайте кілька умов датчиків в один тригер.",
|
||||
"compound_no_conditions": "Ще немає умов — додайте принаймні одну.",
|
||||
"compound_add_condition": "Додати умову",
|
||||
"compound_condition": "Умова",
|
||||
"compound_remove_condition": "Видалити умову",
|
||||
"card_title": "Заголовок",
|
||||
"card_show_header": "Показувати заголовок зі статистикою",
|
||||
"card_show_actions": "Показувати кнопки дій",
|
||||
"card_compact": "Компактний режим",
|
||||
"card_max_items": "Макс. елементів (0 = всі)",
|
||||
"card_filter_status": "Фільтрувати за статусом",
|
||||
"card_filter_status_help": "Порожньо = показати всі статуси.",
|
||||
"card_filter_objects": "Фільтрувати за об'єктами",
|
||||
"card_filter_objects_help": "Порожньо = показати всі об'єкти.",
|
||||
"card_filter_entities": "Фільтрувати за сутностями (entity_ids)",
|
||||
"card_filter_entities_help": "Виберіть сутності sensor / binary_sensor з цієї інтеграції. Порожньо = всі.",
|
||||
"card_loading_objects": "Завантаження об'єктів…",
|
||||
"card_load_error": "Не вдалося завантажити об'єкти — перевірте WebSocket-з'єднання.",
|
||||
"card_no_tasks_title": "Поки немає завдань обслуговування",
|
||||
"card_no_tasks_cta": "→ Створіть на панелі Maintenance",
|
||||
"no_objects": "Поки немає об'єктів.",
|
||||
"action_error": "Дія не вдалась. Спробуйте ще раз.",
|
||||
"area_id_optional": "Зона (необов'язково)",
|
||||
"installation_date_optional": "Дата встановлення (необов'язково)",
|
||||
"warranty_expiry_optional": "Закінчення гарантії (необов'язково)",
|
||||
"warranty": "Гарантія",
|
||||
"warranty_valid_until": "дійсна до {date}",
|
||||
"warranty_expires_in": "закінчується через {days} дн.",
|
||||
"warranty_expired": "закінчилася",
|
||||
"cal_past_windows": "Минулі вікна",
|
||||
"cal_forward_windows": "Майбутні вікна",
|
||||
"history_edit_title": "Редагувати запис історії",
|
||||
"history_edit_timestamp": "Позначка часу",
|
||||
"manufacturer": "Виробник",
|
||||
"model": "Модель",
|
||||
"area": "Зона",
|
||||
"actions": "Дії",
|
||||
"view_mode_label": "Вигляд",
|
||||
"view_cards": "Картки",
|
||||
"view_table": "Таблиця",
|
||||
"objects_table_columns_label": "Стовпці таблиці об'єктів",
|
||||
"objects_table_columns_hint": "Виберіть, які стовпці показувати в табличному вигляді об'єктів.",
|
||||
"custom_icon_optional": "Іконка (необов'язково, наприклад mdi:wrench)",
|
||||
"task_enabled": "Завдання увімкнено",
|
||||
"skip_reason_prompt": "Пропустити це завдання?",
|
||||
"reason_optional": "Причина (необов'язково)",
|
||||
"reset_date_prompt": "Позначити як виконане?",
|
||||
"reset_date_optional": "Дата останнього виконання (необов'язково, типово: сьогодні)",
|
||||
"notes_label": "Примітки",
|
||||
"documentation_label": "Документація",
|
||||
"no_nfc_tag": "— Без тега —",
|
||||
"dashboard": "Дашборд",
|
||||
"tab_today": "Сьогодні",
|
||||
"palette_placeholder": "Пошук об'єктів і завдань…",
|
||||
"palette_no_results": "Немає збігів",
|
||||
"palette_hint": "↑↓ навігація · Enter відкрити · Esc закрити",
|
||||
"today_all_caught_up": "Усе зроблено! Цього тижня нічого немає.",
|
||||
"today_overdue": "Прострочені",
|
||||
"today_due_today": "Сьогодні",
|
||||
"today_this_week": "Цього тижня",
|
||||
"settings": "Налаштування",
|
||||
"settings_features": "Розширені функції",
|
||||
"settings_features_desc": "Увімкніть або вимкніть розширені функції. Вимкнення приховує їх з інтерфейсу, але не видаляє дані.",
|
||||
"feat_adaptive": "Адаптивне планування",
|
||||
"feat_adaptive_desc": "Навчатися оптимальним інтервалам з історії обслуговування",
|
||||
"feat_predictions": "Прогнози за сенсорами",
|
||||
"feat_predictions_desc": "Прогнозувати дати спрацювання за деградацією сенсора",
|
||||
"feat_seasonal": "Сезонні корекції",
|
||||
"feat_seasonal_desc": "Коригувати інтервали на основі сезонних закономірностей",
|
||||
"feat_environmental": "Кореляція з довкіллям",
|
||||
"feat_environmental_desc": "Корелювати інтервали з температурою/вологістю",
|
||||
"feat_budget": "Відстеження бюджету",
|
||||
"feat_budget_desc": "Відстежувати щомісячні та щорічні витрати на обслуговування",
|
||||
"feat_groups": "Групи завдань",
|
||||
"feat_groups_desc": "Організовувати завдання в логічні групи",
|
||||
"feat_checklists": "Чеклісти",
|
||||
"feat_checklists_desc": "Багатокрокові процедури для виконання завдань",
|
||||
"settings_general": "Загальне",
|
||||
"settings_default_warning": "Днів попередження за замовчуванням",
|
||||
"settings_panel_enabled": "Панель у бічному меню",
|
||||
"settings_panel_title": "Заголовок панелі",
|
||||
"settings_notifications": "Сповіщення",
|
||||
"settings_notify_service": "Служба сповіщень",
|
||||
"test_notification": "Тестове сповіщення",
|
||||
"send_test": "Надіслати тест",
|
||||
"testing": "Надсилання…",
|
||||
"test_notification_success": "Тестове сповіщення надіслано",
|
||||
"test_notification_failed": "Не вдалося надіслати тестове сповіщення",
|
||||
"settings_notify_due_soon": "Сповіщати, коли термін наближається",
|
||||
"settings_notify_overdue": "Сповіщати про прострочення",
|
||||
"settings_notify_triggered": "Сповіщати про спрацювання",
|
||||
"settings_interval_hours": "Інтервал повторення (години, 0 = одноразово)",
|
||||
"settings_quiet_hours": "Тихі години",
|
||||
"settings_quiet_start": "Початок",
|
||||
"settings_quiet_end": "Кінець",
|
||||
"settings_max_per_day": "Макс. сповіщень на день (0 = без обмежень)",
|
||||
"settings_bundling": "Групувати сповіщення",
|
||||
"settings_bundle_threshold": "Поріг групування",
|
||||
"settings_reminder_leads": "Extra reminders (days before due)",
|
||||
"settings_reminder_leads_hint": "Comma-separated lead times, e.g. 14, 3, 0 — one extra reminder fires on each matching day. Empty = off.",
|
||||
"settings_actions": "Кнопки дій у мобільних сповіщеннях",
|
||||
"settings_action_complete": "Показувати кнопку «Виконати»",
|
||||
"settings_action_skip": "Показувати кнопку «Пропустити»",
|
||||
"settings_action_snooze": "Показувати кнопку «Відкласти»",
|
||||
"settings_weekly_digest": "Щотижневий огляд",
|
||||
"settings_weekly_digest_hint": "Одне зведене сповіщення в понеділок вранці, коли є завдання.",
|
||||
"settings_warranty_reminder": "Warranty expiry reminder",
|
||||
"settings_warranty_reminder_days": "Days before expiry",
|
||||
"settings_warranty_reminder_hint": "Notify once when an object's warranty is this many days from expiring.",
|
||||
"settings_snooze_hours": "Тривалість відкладення (години)",
|
||||
"settings_budget": "Бюджет",
|
||||
"settings_currency": "Валюта",
|
||||
"settings_budget_monthly": "Щомісячний бюджет",
|
||||
"settings_budget_yearly": "Щорічний бюджет",
|
||||
"settings_budget_alerts": "Сповіщення про бюджет",
|
||||
"settings_budget_threshold": "Поріг сповіщення (%)",
|
||||
"settings_import_export": "Імпорт / Експорт",
|
||||
"settings_export_json": "Експортувати JSON",
|
||||
"settings_export_yaml": "Експортувати YAML",
|
||||
"settings_export_csv": "Експортувати CSV",
|
||||
"settings_import_csv": "Імпортувати CSV",
|
||||
"settings_import_placeholder": "Вставте вміст JSON або CSV сюди…",
|
||||
"settings_import_btn": "Імпортувати",
|
||||
"settings_import_success": "{count} об'єктів успішно імпортовано.",
|
||||
"settings_export_success": "Експорт завантажено.",
|
||||
"settings_saved": "Налаштування збережено.",
|
||||
"settings_include_history": "Включити історію",
|
||||
"sort_alphabetical": "За алфавітом",
|
||||
"sort_due_soonest": "Найближчий термін",
|
||||
"sort_task_count": "Кількість завдань",
|
||||
"sort_area": "Зона",
|
||||
"sort_assigned_user": "Призначений користувач",
|
||||
"sort_group": "Група",
|
||||
"groupby_none": "Без групування",
|
||||
"groupby_area": "За зоною",
|
||||
"groupby_group": "За групою",
|
||||
"groupby_user": "За користувачем",
|
||||
"filter_label": "Фільтр",
|
||||
"user_label": "Користувач",
|
||||
"sort_label": "Сортування",
|
||||
"group_by_label": "Групувати за",
|
||||
"state_value_help": "Використовуйте значення стану HA (зазвичай у нижньому регістрі, напр. \"on\"/\"off\"). Регістр нормалізується при збереженні.",
|
||||
"target_changes_help": "Кількість відповідних переходів, після яких тригер спрацює (за замовчуванням: 1).",
|
||||
"qr_print_title": "Друкувати QR-коди",
|
||||
"qr_print_desc": "Створи сторінку для друку з QR-кодами, які можна вирізати та наклеїти на обладнання.",
|
||||
"qr_print_load": "Завантажити об'єкти",
|
||||
"qr_print_filter": "Фільтр",
|
||||
"qr_print_objects": "Об'єкти",
|
||||
"qr_print_actions": "Дії",
|
||||
"qr_print_url_mode": "Тип посилання",
|
||||
"qr_print_estimate": "Прогноз QR-кодів",
|
||||
"qr_print_over_limit": "ліміт 200, звузь фільтр",
|
||||
"qr_print_generate": "Створити QR-коди",
|
||||
"qr_print_generating": "Створення…",
|
||||
"qr_print_ready": "QR-коди готові",
|
||||
"qr_print_print_button": "Друкувати",
|
||||
"qr_print_empty": "Нічого створювати",
|
||||
"qr_action_skip": "Пропустити",
|
||||
"vacation_title": "Режим відпустки",
|
||||
"vacation_active": "активний",
|
||||
"vacation_ended": "завершено",
|
||||
"vacation_desc": "Сплануй відпустку: сповіщення призупиняються на період плюс кілька буферних днів. Можна налаштувати винятки.",
|
||||
"vacation_enable": "Увімкнути режим відпустки",
|
||||
"vacation_start": "Початок",
|
||||
"vacation_end": "Кінець",
|
||||
"vacation_buffer": "Буфер (днів)",
|
||||
"vacation_exempt_title": "Сповіщати попри відпустку",
|
||||
"vacation_exempt_desc": "Обери завдання, які мають сповіщати навіть під час відпустки (наприклад, критична хімія басейну).",
|
||||
"vacation_load_tasks": "Завантажити завдання",
|
||||
"vacation_preview_btn": "Показати попередній перегляд",
|
||||
"vacation_preview_affected": "завдань зачеплено",
|
||||
"vacation_event_due_soon": "наближається термін",
|
||||
"vacation_event_overdue": "стане простроченим",
|
||||
"vacation_event_triggered_est": "можливе спрацювання сенсора",
|
||||
"vacation_sensor_based": "(сенсорне)",
|
||||
"vacation_action_notify": "Сповіщати все одно",
|
||||
"vacation_action_unsilence": "Знову вимкнути сповіщення",
|
||||
"vacation_marked_complete": "Позначено як виконане",
|
||||
"vacation_marked_skip": "Пропущено",
|
||||
"vacation_end_now": "Завершити відпустку зараз",
|
||||
"unassigned": "Не призначено",
|
||||
"no_area": "Без зони",
|
||||
"has_overdue": "Прострочені завдання",
|
||||
"object": "Об'єкт",
|
||||
"settings_panel_access": "Доступ до панелі",
|
||||
"settings_panel_access_desc": "Адміністратори завжди мають повний доступ. Щоб делегувати створення, редагування та видалення певним не-адмін користувачам, увімкніть це та виберіть їх нижче — інші бачать лише Виконати та Пропустити.",
|
||||
"settings_operator_write": "Дозволити вибраним користувачам створювати, редагувати та видаляти",
|
||||
"settings_operator_write_desc": "Вимкнено: лише адміністратори можуть змінювати вміст. Увімкнено: вибрані користувачі нижче також отримують повний доступ.",
|
||||
"no_non_admin_users": "Не знайдено не-адмін користувачів. Додайте їх у Налаштуваннях → Особи.",
|
||||
"owner_label": "Власник",
|
||||
"feat_completion_actions": "Дії при завершенні",
|
||||
"feat_completion_actions_desc": "Дія HA по завданню при завершенні + QR швидкого завершення з попередньо встановленими значеннями.",
|
||||
"on_complete_action_title": "При завершенні: викликати HA-дію (опційно)",
|
||||
"on_complete_action_desc": "Викликає HA-сервіс, коли завдання завершено — напр., скинути лічильник на пристрої.",
|
||||
"on_complete_action_service": "Сервіс",
|
||||
"on_complete_action_target": "Цільова сутність",
|
||||
"on_complete_action_data": "Дані (JSON, опційно)",
|
||||
"on_complete_action_test": "Тестувати дію",
|
||||
"on_complete_action_test_success": "Успіх",
|
||||
"on_complete_action_test_failed": "Помилка",
|
||||
"quick_complete_defaults_title": "Стандартні значення швидкого завершення (для QR-сканів, опційно)",
|
||||
"quick_complete_defaults_desc": "Попередньо встановлені значення для QR швидкого завершення. Без них QR відкриває діалог.",
|
||||
"quick_complete_defaults_notes": "Нотатки",
|
||||
"quick_complete_defaults_cost": "Вартість",
|
||||
"quick_complete_defaults_duration": "Тривалість (хвилин)",
|
||||
"quick_complete_defaults_feedback_none": "Без зворотного зв'язку",
|
||||
"quick_complete_defaults_feedback_needed": "Було необхідно",
|
||||
"quick_complete_defaults_feedback_not_needed": "Не було необхідно",
|
||||
"quick_complete_success": "Швидко позначено виконаним",
|
||||
"trigger_replaced": "Тригер замінено",
|
||||
"add": "Додати",
|
||||
"show_stats": "Показати статистику + графіки",
|
||||
"hide_stats": "Сховати статистику",
|
||||
"adaptive_no_data": "Поки що недостатньо історії виконань для адаптивного аналізу. Виконайте це завдання ще кілька разів, щоб розблокувати рекомендації щодо інтервалу та графіки надійності.",
|
||||
"suggestion_applied": "Запропонований інтервал застосовано",
|
||||
"vacation_mode": "Режим відпустки",
|
||||
"vacation_status_active": "Активний зараз",
|
||||
"vacation_status_scheduled": "Заплановано",
|
||||
"vacation_status_inactive": "Неактивний",
|
||||
"vacation_end_now_confirm": "Завершити відпустку негайно?",
|
||||
"vacation_exempt_count": "виключено",
|
||||
"vacation_advanced": "Додатково…",
|
||||
"vacation_open_panel": "Відкрити на панелі",
|
||||
"enable": "Увімкнути",
|
||||
"saved": "Збережено",
|
||||
"budget_monthly_set": "Задати місячний",
|
||||
"budget_yearly_set": "Задати річний",
|
||||
"budget_advanced": "Валюта, сповіщення…",
|
||||
"budget_open_panel": "Відкрити на панелі",
|
||||
"groups_empty": "Поки немає груп.",
|
||||
"group_new_placeholder": "Додати групу…",
|
||||
"group_delete_confirm": "Видалити групу «{name}»?",
|
||||
"groups_manage_tasks": "Керування призначеннями завдань…",
|
||||
"groups_open_panel": "Відкрити на панелі",
|
||||
"on_complete_action_target_hint": "Примітка: домен сутності має відповідати сервісу — напр. 'button.press' працює лише з button.*, 'counter.increment' лише з counter.*, 'input_button.press' лише з input_button.* тощо. У разі невідповідності дія мовчки не виконається (HA запише в журнал 'Referenced entities ... missing or not currently available').",
|
||||
"show_all_objects": "Показати всі об'єкти",
|
||||
"show_all_tasks": "Скинути фільтр — показати всі завдання",
|
||||
"filter_to_overdue": "Фільтрувати список лише за простроченими",
|
||||
"filter_to_due_soon": "Фільтрувати список лише за тими, що скоро настануть",
|
||||
"filter_to_triggered": "Фільтрувати список лише за спрацьованими",
|
||||
"open_task": "Відкрити завдання",
|
||||
"show_details": "Показати історію + статистику",
|
||||
"hide_details": "Сховати деталі",
|
||||
"history_empty": "Поки немає історії.",
|
||||
"history_edit_button": "Редагувати запис",
|
||||
"total_cost": "Загальна вартість",
|
||||
"times_performed": "Виконано",
|
||||
"older_entries": "раніше",
|
||||
"open_in_panel": "Відкрити на панелі обслуговування",
|
||||
"skip_reason": "Причина пропуску (необов'язково)",
|
||||
"reset_to_date": "Скинути останнє виконання на",
|
||||
"delete_task_confirm": "Видалити це завдання та його історію?",
|
||||
"delete_object_confirm": "Видалити цей об'єкт і всі його завдання?",
|
||||
"loading": "Завантаження…",
|
||||
"archive": "Архівувати",
|
||||
"undo": "Скасувати",
|
||||
"task_archived": "Завдання архівовано",
|
||||
"object_archived": "Об'єкт архівовано",
|
||||
"unarchive": "Розархівувати",
|
||||
"archived": "В архіві",
|
||||
"show_archived": "Показати архів",
|
||||
"hide_archived": "Сховати архів",
|
||||
"confirm_archive_object": "Архівувати цей об'єкт та його завдання? Історія збережеться, згодом можна відновити.",
|
||||
"settings_archive": "Архів і зберігання",
|
||||
"settings_archive_desc": "Відкладайте виконані одноразові завдання в архів, не видаляючи їх. Архівні елементи приховані й неактивні, але зберігають історію та витрати.",
|
||||
"settings_archive_oneoff_days": "Автоархівування виконаних одноразових завдань через (днів, 0 = вимк)",
|
||||
"settings_delete_archived_oneoff_days": "Автовидалення архівних одноразових завдань через (днів, 0 = ніколи)",
|
||||
"archive_object": "Архівувати об'єкт",
|
||||
"unarchive_object": "Розархівувати об'єкт",
|
||||
"documents": "Документи",
|
||||
"documents_empty": "Ще немає документів.",
|
||||
"doc_upload": "Завантажити файл",
|
||||
"doc_uploading": "Завантаження…",
|
||||
"doc_add_link": "Додати посилання",
|
||||
"doc_link_url": "URL (https://…)",
|
||||
"doc_link_title": "Назва (необов'язково)",
|
||||
"doc_open": "Відкрити",
|
||||
"doc_delete_confirm": "Видалити «{name}»?",
|
||||
"doc_too_large": "Файл завеликий (макс. 25 МБ).",
|
||||
"doc_upload_failed": "Не вдалося завантажити.",
|
||||
"completion_photo_optional": "Completion photo (optional)",
|
||||
"add_photo": "Add photo",
|
||||
"uploading": "Uploading…",
|
||||
"remove": "Remove",
|
||||
"doc_deduped": "Уже збережено в іншому місці — спільний, без додаткового місця.",
|
||||
"doc_dup_in_object": "Цей файл уже прикріплено до цього об'єкта.",
|
||||
"doc_link_invalid": "Дозволені лише посилання http/https.",
|
||||
"doc_cat_manual": "Інструкція",
|
||||
"doc_cat_warranty": "Гарантія",
|
||||
"doc_cat_invoice": "Рахунок",
|
||||
"doc_cat_spare_parts": "Запчастини",
|
||||
"doc_cat_photo": "Фото",
|
||||
"doc_cat_other": "Інше",
|
||||
"doc_link_badge": "Посилання",
|
||||
"doc_storage_title": "Сховище документів",
|
||||
"doc_storage_saved": "Заощаджено дедуплікацією",
|
||||
"doc_storage_refresh": "Оновити",
|
||||
"doc_download": "Завантажити",
|
||||
"doc_close": "Закрити",
|
||||
"doc_camera": "Зробити фото",
|
||||
"doc_drop_hint": "Перетягніть файли сюди",
|
||||
"doc_task_none": "Немає документів, пов'язаних із цим завданням.",
|
||||
"doc_link_existing": "Пов'язати документ…",
|
||||
"doc_attach": "Пов'язати",
|
||||
"doc_unlink": "Відв'язати",
|
||||
"doc_page": "Сторінка",
|
||||
"chart_range_7d": "7д",
|
||||
"chart_range_30d": "30д",
|
||||
"chart_range_90d": "90д",
|
||||
"chart_range_1y": "1р",
|
||||
"chart_since_service": "з останнього обслуговування",
|
||||
"chart_no_stats": "Немає довгострокової статистики для цієї сутності — показано лише значення із записів обслуговування",
|
||||
"auto_complete_on_recovery": "Автозавершення при відновленні датчика",
|
||||
"auto_complete_on_recovery_help": "Записує виконання (оновлює дату останнього обслуговування), коли тригер зникає сам — наприклад, сіль досипано, фільтр замінено.",
|
||||
"doc_search": "Пошук документів…",
|
||||
"doc_search_none": "Немає відповідних документів",
|
||||
"link_device_optional": "Прив'язати до наявного пристрою (необов'язково)",
|
||||
"parent_object_optional": "Батьківський об'єкт (необов'язково)",
|
||||
"parent_none": "(Без батьківського)",
|
||||
"paused": "Призупинено",
|
||||
"pause_object": "Призупинити",
|
||||
"resume_object": "Відновити",
|
||||
"pause_until_prompt": "Заморозити розклади цього об'єкта — нічого не настає і не сповіщає до відновлення. За бажанням укажіть дату автоматичного відновлення.",
|
||||
"pause_until_label": "Відновити (необов'язково)",
|
||||
"object_paused": "Об'єкт призупинено",
|
||||
"object_resumed": "Об'єкт відновлено — розклади перезапущено",
|
||||
"object_paused_badge": "Призупинено",
|
||||
"paused_until_label": "до",
|
||||
"replace_object": "Замінити…",
|
||||
"replace_object_prompt": "Списати цей об'єкт і створити наступника. Історія та витрати лишаються в архіві старого; завдання й документи переходять до нового, лічильники починаються заново.",
|
||||
"replace_name_label": "Ім'я наступника",
|
||||
"object_replaced": "Об'єкт замінено — наступника створено",
|
||||
"reading_unit_label": "Одиниця показання (напр. кВт·год, м³)",
|
||||
"reading_unit_help": "Показується поруч із записаним значенням під час виконання цієї задачі.",
|
||||
"reading_value_label": "Показання",
|
||||
"reading_label": "Показання",
|
||||
"settings_templates_label": "Галерея шаблонів",
|
||||
"settings_templates_hint": "Зніміть позначку з шаблонів, які вам не потрібні — вони зникнуть із вибору «З шаблону» (панель і config flow). Більше нічого не змінюється; можна ввімкнути знову будь-коли.",
|
||||
"worksheet": "Робочий аркуш",
|
||||
"worksheet_scan_view": "Скануйте, щоб відкрити задачу",
|
||||
"worksheet_scan_complete": "Скануйте для виконання",
|
||||
"worksheet_manual_excerpt": "Витяг з посібника",
|
||||
"worksheet_pages": "сторінки",
|
||||
"worksheet_printed": "Надруковано",
|
||||
"worksheet_never": "Ніколи"
|
||||
}
|
||||
@@ -0,0 +1,696 @@
|
||||
{
|
||||
"maintenance": "维护",
|
||||
"objects": "维护项",
|
||||
"tasks": "任务",
|
||||
"overdue": "逾期",
|
||||
"due_soon": "即将到期",
|
||||
"triggered": "已触发",
|
||||
"trigger_replaced": "触发器已更换",
|
||||
"ok": "正常",
|
||||
"all": "全部",
|
||||
"new_object": "+ 新建维护项",
|
||||
"templates_from": "从模板",
|
||||
"templates_title": "从模板开始",
|
||||
"templates_task_count": "{n} 个任务",
|
||||
"template_created": "已从模板创建",
|
||||
"onboard_hint": "添加第一个对象以开始跟踪维护。",
|
||||
"edit": "编辑",
|
||||
"duplicate": "复制",
|
||||
"task_duplicated": "任务已复制",
|
||||
"object_duplicated": "对象已复制",
|
||||
"delete": "删除",
|
||||
"add_task": "+ 添加任务",
|
||||
"complete": "完成",
|
||||
"completed": "已完成",
|
||||
"skip": "跳过",
|
||||
"skipped": "已跳过",
|
||||
"missed": "Missed",
|
||||
"reset": "重置",
|
||||
"snooze": "Snooze",
|
||||
"snoozed": "Snoozed",
|
||||
"cancel": "取消",
|
||||
"bulk_select": "选择",
|
||||
"bulk_select_all": "全选",
|
||||
"bulk_n_selected": "已选 {n} 项",
|
||||
"bulk_completed": "已完成 {n} 个任务",
|
||||
"bulk_archived": "已归档 {n} 个任务",
|
||||
"completing": "正在完成…",
|
||||
"interval": "间隔",
|
||||
"warning": "预警",
|
||||
"last_performed": "上次执行",
|
||||
"next_due": "下次到期",
|
||||
"days_until_due": "距离到期天数",
|
||||
"avg_duration": "平均耗时",
|
||||
"trigger": "触发器",
|
||||
"trigger_type": "触发类型",
|
||||
"threshold_above": "超过阈值",
|
||||
"threshold_below": "低于阈值",
|
||||
"threshold": "阈值",
|
||||
"counter": "计数器",
|
||||
"state_change": "状态变化",
|
||||
"runtime": "运行时间",
|
||||
"runtime_hours": "目标运行时长 (小时)",
|
||||
"target_value": "目标值",
|
||||
"baseline": "基准线",
|
||||
"target_changes": "目标变化次数",
|
||||
"for_minutes": "持续 (分钟)",
|
||||
"time_based": "基于时间",
|
||||
"sensor_based": "基于传感器",
|
||||
"manual": "手动",
|
||||
"one_time": "一次性",
|
||||
"weekdays": "工作日",
|
||||
"nth_weekday": "每月第 N 个工作日",
|
||||
"day_of_month": "每月某天",
|
||||
"recurrence_on_days": "重复日期",
|
||||
"recurrence_occurrence": "发生次数",
|
||||
"recurrence_weekday": "星期",
|
||||
"recurrence_day": "每月日期 (1–31)",
|
||||
"recurrence_last_day": "Last day of the month",
|
||||
"recurrence_business_day": "Business days only (roll back from weekend)",
|
||||
"recurrence_offset": "Offset (days, ±)",
|
||||
"recurrence_offset_help": "Shift the date by ±N days, e.g. -2 = two days before.",
|
||||
"last_day_month": "Last day of month",
|
||||
"last_business_day_month": "Last business day",
|
||||
"ord_1": "第 1",
|
||||
"ord_2": "第 2",
|
||||
"ord_3": "第 3",
|
||||
"ord_4": "第 4",
|
||||
"ord_5": "第 5",
|
||||
"ord_last": "最后",
|
||||
"day_word": "日",
|
||||
"interval_value": "间隔数值",
|
||||
"interval_unit": "单位",
|
||||
"unit_days": "天",
|
||||
"unit_weeks": "周",
|
||||
"unit_months": "月",
|
||||
"unit_years": "年",
|
||||
"due_date": "到期日期",
|
||||
"cleaning": "清洁",
|
||||
"inspection": "检查",
|
||||
"replacement": "更换",
|
||||
"calibration": "校准",
|
||||
"service": "保养",
|
||||
"reading": "读数",
|
||||
"custom": "自定义",
|
||||
"history": "历史记录",
|
||||
"cost": "成本",
|
||||
"report_button": "报告",
|
||||
"report_title": "维护报告",
|
||||
"report_generated": "生成于",
|
||||
"report_times_done": "完成次数",
|
||||
"report_total_cost": "总费用",
|
||||
"report_every": "每 {n} {unit}",
|
||||
"report_notes": "备注",
|
||||
"report_col_type": "类型",
|
||||
"report_col_status": "状态",
|
||||
"report_col_schedule": "计划",
|
||||
"duration": "耗时",
|
||||
"both": "两者",
|
||||
"trigger_val": "触发值",
|
||||
"complete_title": "完成: ",
|
||||
"checklist": "检查清单",
|
||||
"checklist_steps_optional": "检查步骤 (可选)",
|
||||
"checklist_placeholder": "清理过滤器\n更换密封圈\n测试压力",
|
||||
"checklist_help": "每行一个步骤。最多 100 项。",
|
||||
"err_too_long": "{field}: 太长 (最多 {n} 个字符)",
|
||||
"err_too_short": "{field}: 太短 (最少 {n} 个字符)",
|
||||
"err_value_too_high": "{field}: 太大 (最大 {n})",
|
||||
"err_value_too_low": "{field}: 太小 (最小 {n})",
|
||||
"err_required": "{field}: 必填项",
|
||||
"err_wrong_type": "{field}: 类型错误 (预期: {type})",
|
||||
"err_invalid_choice": "{field}: 无效选项",
|
||||
"err_invalid_value": "{field}: 无效数值",
|
||||
"feat_schedule_time": "具体时间调度",
|
||||
"feat_schedule_time_desc": "任务将在特定时间点变为逾期,而非默认的午夜。",
|
||||
"schedule_time_optional": "到期时间 (可选, HH:MM)",
|
||||
"schedule_time_help": "留空 = 午夜 (默认)。基于 HA 时区。",
|
||||
"at_time": "于",
|
||||
"notes_optional": "备注 (可选)",
|
||||
"cost_optional": "成本 (可选)",
|
||||
"duration_minutes": "耗时 (分钟, 可选)",
|
||||
"days": "天",
|
||||
"day": "天",
|
||||
"today": "今天",
|
||||
"d_overdue": "天逾期",
|
||||
"no_tasks": "尚无维护任务。请创建一个维护项开始使用。",
|
||||
"no_tasks_short": "无任务",
|
||||
"no_history": "尚无历史记录。",
|
||||
"show_all": "全部显示",
|
||||
"cost_duration_chart": "成本与耗时",
|
||||
"installed": "已安装",
|
||||
"confirm_delete_object": "确定删除此维护项及其所有任务吗?",
|
||||
"confirm_delete_task": "确定删除此任务吗?",
|
||||
"min": "最小",
|
||||
"max": "最大",
|
||||
"save": "保存",
|
||||
"saving": "正在保存…",
|
||||
"edit_task": "编辑任务",
|
||||
"new_task": "新建维护任务",
|
||||
"task_name": "任务名称",
|
||||
"maintenance_type": "维护类型",
|
||||
"priority": "优先级",
|
||||
"labels": "Labels",
|
||||
"labels_placeholder": "e.g. safety, seasonal, tenant-visible",
|
||||
"labels_help": "Comma-separated tags for filtering and reporting.",
|
||||
"priority_low": "低",
|
||||
"priority_normal": "普通",
|
||||
"priority_high": "高",
|
||||
"schedule_type": "计划类型",
|
||||
"interval_days": "间隔 (天)",
|
||||
"warning_days": "预警天数",
|
||||
"earliest_completion_days": "Earliest completion (days before due)",
|
||||
"earliest_completion_days_help": "Leave empty to allow completing any time. 0 = only on/after the due date.",
|
||||
"last_performed_optional": "上次执行时间 (可选)",
|
||||
"interval_anchor": "计划锚点",
|
||||
"anchor_completion": "从完成日期起算",
|
||||
"anchor_planned": "从计划日期起算 (无偏差)",
|
||||
"edit_object": "编辑维护项",
|
||||
"name": "名称",
|
||||
"manufacturer_optional": "制造商 (可选)",
|
||||
"model_optional": "型号 (可选)",
|
||||
"serial_number_optional": "序列号 (可选)",
|
||||
"serial_number_label": "序列号",
|
||||
"documentation_url_label": "手册",
|
||||
"object_notes_label": "备注",
|
||||
"sort_due_date": "到期时间",
|
||||
"sort_object": "维护项名称",
|
||||
"sort_type": "类型",
|
||||
"sort_task_name": "任务名称",
|
||||
"all_objects": "所有维护项",
|
||||
"tasks_lower": "任务",
|
||||
"no_tasks_yet": "尚无任务",
|
||||
"add_first_task": "添加首个任务",
|
||||
"trigger_configuration": "触发器配置",
|
||||
"entity_id": "实体 ID",
|
||||
"comma_separated": "逗号分隔",
|
||||
"entity_logic": "实体逻辑",
|
||||
"entity_logic_any": "任一实体触发",
|
||||
"entity_logic_all": "所有实体均触发",
|
||||
"entities": "实体",
|
||||
"attribute_optional": "属性 (可选, 留空 = 状态)",
|
||||
"use_entity_state": "使用实体状态 (不使用属性)",
|
||||
"trigger_above": "高于此值触发",
|
||||
"trigger_below": "低于此值触发",
|
||||
"for_at_least_minutes": "持续至少 (分钟)",
|
||||
"safety_interval_days": "安全间隔 (天, 可选)",
|
||||
"safety_interval": "安全间隔 (可选)",
|
||||
"delta_mode": "增量模式",
|
||||
"from_state_optional": "起始状态 (可选)",
|
||||
"to_state_optional": "目标状态 (可选)",
|
||||
"documentation_url_optional": "文档链接 (可选)",
|
||||
"object_notes_optional": "备注 (可选)",
|
||||
"nfc_tag_id_optional": "NFC 标签 ID (可选)",
|
||||
"nfc_tags_empty_help": "Home Assistant 中尚未注册任何 NFC 标签。",
|
||||
"nfc_tags_open_settings": "打开标签设置",
|
||||
"nfc_tags_refresh": "刷新",
|
||||
"environmental_entity_optional": "环境传感器 (可选)",
|
||||
"environmental_entity_helper": "例如:sensor.outdoor_temperature — 根据环境条件自动调整间隔",
|
||||
"environmental_attribute_optional": "环境属性 (可选)",
|
||||
"nfc_tag_id": "NFC 标签 ID",
|
||||
"nfc_linked": "NFC 标签已链接",
|
||||
"nfc_link_hint": "点击链接 NFC 标签",
|
||||
"responsible_user": "负责人",
|
||||
"shared_with": "Shared with (rotation)",
|
||||
"shared_with_help": "Pick multiple people to share this task; the responsible person rotates on each completion.",
|
||||
"rotation_strategy": "Rotation",
|
||||
"rotation_none": "No rotation",
|
||||
"rotation_round_robin": "Round-robin",
|
||||
"rotation_least_completed": "Least completed",
|
||||
"rotation_random": "Random",
|
||||
"no_user_assigned": "(未分配)",
|
||||
"all_users": "所有用户",
|
||||
"my_tasks": "我的任务",
|
||||
"tab_calendar": "日历",
|
||||
"cal_no_events": "无维护事项",
|
||||
"cal_window_7": "7 天",
|
||||
"cal_window_14": "14 天",
|
||||
"cal_window_30": "30 天",
|
||||
"cal_window_365": "1 年",
|
||||
"cal_every_n_days": "每 {n} 天",
|
||||
"cal_source_time": "基于时间",
|
||||
"cal_source_time_adaptive": "基于时间 (自适应)",
|
||||
"cal_source_sensor": "基于传感器",
|
||||
"cal_predicted": "预测",
|
||||
"cal_confidence_high": "高置信度",
|
||||
"cal_confidence_medium": "中置信度",
|
||||
"cal_confidence_low": "低置信度",
|
||||
"budget_monthly": "月度预算",
|
||||
"budget_yearly": "年度预算",
|
||||
"groups": "分组",
|
||||
"new_group": "新建分组",
|
||||
"edit_group": "编辑分组",
|
||||
"no_groups": "尚无分组",
|
||||
"delete_group": "删除分组",
|
||||
"delete_group_confirm": "确定删除分组 '{name}' 吗?",
|
||||
"group_select_tasks": "选择任务",
|
||||
"group_name_required": "名称必填",
|
||||
"description_optional": "描述 (可选)",
|
||||
"selected": "已选择",
|
||||
"loading_chart": "正在加载图表数据...",
|
||||
"hide_outliers": "隐藏离群值(传感器故障)",
|
||||
"was_maintenance_needed": "此次维护是否确实需要?",
|
||||
"feedback_needed": "需要",
|
||||
"feedback_not_needed": "不需要",
|
||||
"feedback_not_sure": "不确定",
|
||||
"suggested_interval": "建议间隔",
|
||||
"apply_suggestion": "应用建议",
|
||||
"reanalyze": "重新分析",
|
||||
"reanalyze_result": "新分析结果",
|
||||
"reanalyze_insufficient_data": "数据不足,无法生成建议",
|
||||
"data_points": "个数据点",
|
||||
"dismiss_suggestion": "忽略",
|
||||
"confidence_low": "低",
|
||||
"confidence_medium": "中",
|
||||
"confidence_high": "高",
|
||||
"recommended": "推荐",
|
||||
"seasonal_awareness": "季节性感知",
|
||||
"edit_seasonal_overrides": "编辑季节性修正系数",
|
||||
"seasonal_overrides_title": "季节性修正系数 (覆盖)",
|
||||
"seasonal_overrides_hint": "每月系数 (0.1–5.0)。留空则自动学习。",
|
||||
"seasonal_override_invalid": "无效数值",
|
||||
"seasonal_override_range": "系数必须介于 0.1 到 5.0 之间",
|
||||
"clear_all": "全部清除",
|
||||
"seasonal_chart_title": "季节性因素",
|
||||
"seasonal_learned": "自动学习",
|
||||
"seasonal_manual": "手动设置",
|
||||
"month_jan": "一月",
|
||||
"month_feb": "二月",
|
||||
"month_mar": "三月",
|
||||
"month_apr": "四月",
|
||||
"month_may": "五月",
|
||||
"month_jun": "六月",
|
||||
"month_jul": "七月",
|
||||
"month_aug": "八月",
|
||||
"month_sep": "九月",
|
||||
"month_oct": "十月",
|
||||
"month_nov": "十一月",
|
||||
"month_dec": "十二月",
|
||||
"sensor_prediction": "传感器预测",
|
||||
"degradation_trend": "趋势",
|
||||
"trend_rising": "上升",
|
||||
"trend_falling": "下降",
|
||||
"trend_stable": "稳定",
|
||||
"trend_insufficient_data": "数据不足",
|
||||
"days_until_threshold": "距离阈值天数",
|
||||
"threshold_exceeded": "已超过阈值",
|
||||
"environmental_adjustment": "环境因子",
|
||||
"sensor_prediction_urgency": "传感器预测约 {days} 天后达到阈值",
|
||||
"day_short": "天",
|
||||
"weibull_reliability_curve": "韦伯可靠性曲线",
|
||||
"weibull_failure_probability": "故障概率",
|
||||
"weibull_r_squared": "拟合度 R²",
|
||||
"beta_early_failures": "早期失效",
|
||||
"beta_random_failures": "随机失效",
|
||||
"beta_wear_out": "耗损失效",
|
||||
"beta_highly_predictable": "高度可预测",
|
||||
"confidence_interval": "置信区间",
|
||||
"confidence_conservative": "保守",
|
||||
"confidence_aggressive": "进取",
|
||||
"current_interval_marker": "当前间隔",
|
||||
"recommended_marker": "推荐",
|
||||
"characteristic_life": "特征寿命",
|
||||
"chart_mini_sparkline": "趋势走势图",
|
||||
"chart_history": "成本与时长历史",
|
||||
"chart_seasonal": "12个月季节性因素图表",
|
||||
"chart_weibull": "韦伯可靠性曲线图表",
|
||||
"chart_sparkline": "传感器触发值图表",
|
||||
"days_progress": "天数进度",
|
||||
"qr_code": "二维码",
|
||||
"qr_generating": "正在生成二维码…",
|
||||
"qr_error": "无法生成二维码。",
|
||||
"qr_error_no_url": "未配置 HA URL。请前往“设置 → 系统 → 网络”设置外部或内部 URL。",
|
||||
"save_error": "保存失败。请重试。",
|
||||
"qr_print": "打印",
|
||||
"qr_download": "下载 SVG",
|
||||
"qr_action": "扫码后动作",
|
||||
"qr_action_view": "查看维护信息",
|
||||
"qr_action_complete": "标记维护为已完成",
|
||||
"qr_url_mode": "链接类型",
|
||||
"qr_mode_companion": "Companion App",
|
||||
"qr_mode_local": "本地 (mDNS)",
|
||||
"qr_mode_server": "服务器 URL",
|
||||
"overview": "概览",
|
||||
"analysis": "分析",
|
||||
"recent_activities": "近期活动",
|
||||
"search_notes": "搜索备注",
|
||||
"avg_cost": "平均成本",
|
||||
"no_advanced_features": "未启用高级功能",
|
||||
"no_advanced_features_hint": "请在集成设置中启用“自适应间隔”或“季节性模式”以在此查看分析数据。",
|
||||
"analysis_not_enough_data": "暂无足够的分析数据。",
|
||||
"analysis_not_enough_data_hint": "韦伯分析需要至少 5 次完成记录;季节性模式在每月有 6 个以上数据点后可见。",
|
||||
"analysis_manual_task_hint": "无间隔的手动任务不会生成分析数据。",
|
||||
"completions": "次完成记录",
|
||||
"current": "当前",
|
||||
"shorter": "较短",
|
||||
"longer": "较长",
|
||||
"normal": "正常",
|
||||
"disabled": "已禁用",
|
||||
"compound_logic": "组合逻辑",
|
||||
"compound": "组合(多个条件)",
|
||||
"compound_logic_and": "AND — 所有条件都需满足",
|
||||
"compound_logic_or": "OR — 任一条件即可",
|
||||
"compound_help": "将多个传感器条件组合成一个触发器。",
|
||||
"compound_no_conditions": "还没有条件 — 至少添加一个。",
|
||||
"compound_add_condition": "添加条件",
|
||||
"compound_condition": "条件",
|
||||
"compound_remove_condition": "删除条件",
|
||||
"card_title": "标题",
|
||||
"card_show_header": "显示统计信息页眉",
|
||||
"card_show_actions": "显示操作按钮",
|
||||
"card_compact": "紧凑模式",
|
||||
"card_max_items": "最大显示项 (0 = 全部)",
|
||||
"card_filter_status": "按状态过滤",
|
||||
"card_filter_status_help": "留空则显示所有状态。",
|
||||
"card_filter_objects": "按维护项过滤",
|
||||
"card_filter_objects_help": "留空则显示所有维护项。",
|
||||
"card_filter_entities": "按实体过滤 (entity_ids)",
|
||||
"card_filter_entities_help": "选择该集成的传感器或二进制传感器实体。留空则显示全部。",
|
||||
"card_loading_objects": "正在加载维护项…",
|
||||
"card_load_error": "无法加载维护项 — 请检查 WebSocket 连接。",
|
||||
"card_no_tasks_title": "暂无维护任务",
|
||||
"card_no_tasks_cta": "→ 请前往维护面板创建",
|
||||
"no_objects": "暂无维护项。",
|
||||
"action_error": "操作失败。请重试。",
|
||||
"area_id_optional": "区域 (可选)",
|
||||
"installation_date_optional": "安装日期 (可选)",
|
||||
"warranty_expiry_optional": "保修到期 (可选)",
|
||||
"warranty": "保修",
|
||||
"warranty_valid_until": "有效期至 {date}",
|
||||
"warranty_expires_in": "{days} 天后到期",
|
||||
"warranty_expired": "已过期",
|
||||
"cal_past_windows": "过去的窗口",
|
||||
"cal_forward_windows": "未来的窗口",
|
||||
"history_edit_title": "编辑历史记录",
|
||||
"history_edit_timestamp": "时间戳",
|
||||
"manufacturer": "制造商",
|
||||
"model": "型号",
|
||||
"area": "区域",
|
||||
"actions": "操作",
|
||||
"view_mode_label": "视图",
|
||||
"view_cards": "卡片视图",
|
||||
"view_table": "表格视图",
|
||||
"objects_table_columns_label": "对象表格列",
|
||||
"objects_table_columns_hint": "选择对象表格视图中显示的列。",
|
||||
"custom_icon_optional": "图标 (可选,例如 mdi:wrench)",
|
||||
"task_enabled": "任务已启用",
|
||||
"skip_reason_prompt": "跳过此任务?",
|
||||
"reason_optional": "原因 (可选)",
|
||||
"reset_date_prompt": "标记任务为已执行?",
|
||||
"reset_date_optional": "最后执行日期 (可选,默认为今天)",
|
||||
"notes_label": "备注",
|
||||
"documentation_label": "文档",
|
||||
"no_nfc_tag": "— 无标签 —",
|
||||
"dashboard": "仪表盘",
|
||||
"tab_today": "今天",
|
||||
"palette_placeholder": "搜索对象和任务…",
|
||||
"palette_no_results": "无匹配",
|
||||
"palette_hint": "↑↓ 导航 · Enter 打开 · Esc 关闭",
|
||||
"today_all_caught_up": "全部完成!本周没有待办。",
|
||||
"today_overdue": "逾期",
|
||||
"today_due_today": "今天到期",
|
||||
"today_this_week": "本周",
|
||||
"settings": "设置",
|
||||
"settings_features": "高级功能",
|
||||
"settings_features_desc": "启用或禁用高级功能。禁用将从界面隐藏相关功能,但不会删除数据。",
|
||||
"feat_adaptive": "自适应计划",
|
||||
"feat_adaptive_desc": "根据维护历史学习最佳间隔",
|
||||
"feat_predictions": "传感器预测",
|
||||
"feat_predictions_desc": "根据传感器损耗趋势预测触发日期",
|
||||
"feat_seasonal": "季节性调整",
|
||||
"feat_seasonal_desc": "根据季节性模式自动调整间隔",
|
||||
"feat_environmental": "环境关联",
|
||||
"feat_environmental_desc": "将维护间隔与温度或湿度关联",
|
||||
"feat_budget": "预算追踪",
|
||||
"feat_budget_desc": "追踪月度及年度维护支出",
|
||||
"feat_groups": "任务组",
|
||||
"feat_groups_desc": "将任务组织进逻辑组",
|
||||
"feat_checklists": "检查清单",
|
||||
"feat_checklists_desc": "为任务完成提供多步骤操作流程",
|
||||
"settings_general": "常规",
|
||||
"settings_default_warning": "默认预警天数",
|
||||
"settings_panel_enabled": "侧边栏面板",
|
||||
"settings_panel_title": "侧边栏面板标题",
|
||||
"settings_notifications": "通知",
|
||||
"settings_notify_service": "通知服务",
|
||||
"test_notification": "测试通知",
|
||||
"send_test": "发送测试",
|
||||
"testing": "正在发送…",
|
||||
"test_notification_success": "测试通知已发送",
|
||||
"test_notification_failed": "测试通知发送失败",
|
||||
"settings_notify_due_soon": "到期前通知提醒",
|
||||
"settings_notify_overdue": "超期后通知提醒",
|
||||
"settings_notify_triggered": "触发时通知提醒",
|
||||
"settings_interval_hours": "重复提醒间隔 (小时,0 = 仅一次)",
|
||||
"settings_quiet_hours": "静默时段",
|
||||
"settings_quiet_start": "开始时间",
|
||||
"settings_quiet_end": "结束时间",
|
||||
"settings_max_per_day": "每日最高通知次数 (0 = 不限)",
|
||||
"settings_bundling": "合并通知",
|
||||
"settings_bundle_threshold": "合并阈值",
|
||||
"settings_reminder_leads": "Extra reminders (days before due)",
|
||||
"settings_reminder_leads_hint": "Comma-separated lead times, e.g. 14, 3, 0 — one extra reminder fires on each matching day. Empty = off.",
|
||||
"settings_actions": "移动端操作按钮",
|
||||
"settings_action_complete": "显示“完成”按钮",
|
||||
"settings_action_skip": "显示“跳过”按钮",
|
||||
"settings_action_snooze": "显示“稍后”按钮",
|
||||
"settings_weekly_digest": "每周摘要",
|
||||
"settings_weekly_digest_hint": "当有待办任务时,周一早上发送一条汇总通知。",
|
||||
"settings_warranty_reminder": "Warranty expiry reminder",
|
||||
"settings_warranty_reminder_days": "Days before expiry",
|
||||
"settings_warranty_reminder_hint": "Notify once when an object's warranty is this many days from expiring.",
|
||||
"settings_snooze_hours": "稍后提醒间隔 (小时)",
|
||||
"settings_budget": "预算",
|
||||
"settings_currency": "货币单位",
|
||||
"settings_budget_monthly": "月度预算",
|
||||
"settings_budget_yearly": "年度预算",
|
||||
"settings_budget_alerts": "预算警报",
|
||||
"settings_budget_threshold": "警报阈值 (%)",
|
||||
"settings_import_export": "导入 / 导出",
|
||||
"settings_export_json": "导出 JSON",
|
||||
"settings_export_yaml": "导出 YAML",
|
||||
"settings_export_csv": "导出 CSV",
|
||||
"settings_import_csv": "导入 CSV",
|
||||
"settings_import_placeholder": "在此粘贴 JSON 或 CSV 内容…",
|
||||
"settings_import_btn": "导入",
|
||||
"settings_import_success": "成功导入 {count} 个维护项。",
|
||||
"settings_export_success": "导出已下载。",
|
||||
"settings_saved": "设置已保存。",
|
||||
"settings_include_history": "包含历史记录",
|
||||
"sort_alphabetical": "字母排序",
|
||||
"sort_due_soonest": "最近到期",
|
||||
"sort_task_count": "任务数量",
|
||||
"sort_area": "区域",
|
||||
"sort_assigned_user": "负责人",
|
||||
"sort_group": "任务组",
|
||||
"groupby_none": "不分组",
|
||||
"groupby_area": "按区域分组",
|
||||
"groupby_group": "按分组",
|
||||
"groupby_user": "按负责人",
|
||||
"filter_label": "过滤器",
|
||||
"user_label": "用户",
|
||||
"sort_label": "排序",
|
||||
"group_by_label": "分组依据",
|
||||
"state_value_help": "使用 HA 状态值(通常为小写,例如 \"on\"/\"off\")。保存时大小写会自动规范化。",
|
||||
"target_changes_help": "触发前需要满足的状态转换次数(默认为 1)。",
|
||||
"qr_print_title": "打印二维码",
|
||||
"qr_print_desc": "生成一张包含多个二维码的可打印页面,方便裁剪并贴在设备上。",
|
||||
"qr_print_load": "加载设备",
|
||||
"qr_print_filter": "过滤器",
|
||||
"qr_print_objects": "设备",
|
||||
"qr_print_actions": "动作",
|
||||
"qr_print_url_mode": "链接类型",
|
||||
"qr_print_estimate": "预计二维码数量",
|
||||
"qr_print_over_limit": "上限为 200,请缩小过滤范围",
|
||||
"qr_print_generate": "生成二维码",
|
||||
"qr_print_generating": "正在生成…",
|
||||
"qr_print_ready": "二维码已就绪",
|
||||
"qr_print_print_button": "打印",
|
||||
"qr_print_empty": "无内容可生成",
|
||||
"qr_action_skip": "跳过",
|
||||
"vacation_title": "度假模式",
|
||||
"vacation_active": "已激活",
|
||||
"vacation_ended": "已结束",
|
||||
"vacation_desc": "计划假期:在此时段及设定的缓冲天数内将暂停通知。您可以为特定任务设置例外。",
|
||||
"vacation_enable": "开启度假模式",
|
||||
"vacation_start": "开始日期",
|
||||
"vacation_end": "结束日期",
|
||||
"vacation_buffer": "缓冲(天)",
|
||||
"vacation_exempt_title": "度假期间仍保持通知的任务",
|
||||
"vacation_exempt_desc": "选择在假期中仍需接收通知的任务(例如关键的水池维护)。",
|
||||
"vacation_load_tasks": "加载任务",
|
||||
"vacation_preview_btn": "显示预览",
|
||||
"vacation_preview_affected": "受影响的任务",
|
||||
"vacation_event_due_soon": "即将到期",
|
||||
"vacation_event_overdue": "将超期",
|
||||
"vacation_event_triggered_est": "可能触发传感器",
|
||||
"vacation_sensor_based": "(基于传感器)",
|
||||
"vacation_action_notify": "依然通知",
|
||||
"vacation_action_unsilence": "恢复静默",
|
||||
"vacation_marked_complete": "已标记为完成",
|
||||
"vacation_marked_skip": "已跳过",
|
||||
"vacation_end_now": "立即结束度假",
|
||||
"add": "添加",
|
||||
"show_stats": "显示统计与图表",
|
||||
"hide_stats": "隐藏统计",
|
||||
"adaptive_no_data": "尚无足够的完成记录用于自适应分析。请完成此任务几次,以解锁间隔建议和可靠性图表。",
|
||||
"suggestion_applied": "已应用建议间隔",
|
||||
"vacation_mode": "度假模式",
|
||||
"vacation_status_active": "当前激活",
|
||||
"vacation_status_scheduled": "已计划",
|
||||
"vacation_status_inactive": "未激活",
|
||||
"vacation_end_now_confirm": "确定立即结束度假吗?",
|
||||
"vacation_exempt_count": "例外项",
|
||||
"vacation_advanced": "高级选项…",
|
||||
"vacation_open_panel": "在面板中打开",
|
||||
"enable": "启用",
|
||||
"saved": "已保存",
|
||||
"budget_monthly_set": "设置月度预算",
|
||||
"budget_yearly_set": "设置年度预算",
|
||||
"budget_advanced": "货币、预警…",
|
||||
"budget_open_panel": "在面板中打开",
|
||||
"groups_empty": "尚无分组。",
|
||||
"group_new_placeholder": "添加分组…",
|
||||
"group_delete_confirm": "确定删除分组 \"{name}\" 吗?",
|
||||
"groups_manage_tasks": "管理任务分配…",
|
||||
"groups_open_panel": "在面板中打开",
|
||||
"unassigned": "未分配",
|
||||
"no_area": "无区域",
|
||||
"has_overdue": "有超期任务",
|
||||
"object": "设备",
|
||||
"settings_panel_access": "面板访问权限",
|
||||
"settings_panel_access_desc": "管理员始终拥有完整访问权限。要将创建、编辑和删除权限委派给特定的普通用户,请开启此项并在下方选择他们 — 其他用户将仅能看到“完成”和“跳过”。",
|
||||
"settings_operator_write": "允许所选用户创建、编辑和删除",
|
||||
"settings_operator_write_desc": "关闭时:仅管理员可修改内容。开启时:下方所选用户也获得完整访问权限。",
|
||||
"no_non_admin_users": "未找到普通用户。请在“设置 -> 人员”中添加。",
|
||||
"owner_label": "所有者",
|
||||
"feat_completion_actions": "完成动作",
|
||||
"feat_completion_actions_desc": "为每个任务配置完成时的 HA 动作 + 预设值的快速完成二维码。",
|
||||
"on_complete_action_title": "完成时:触发 HA 动作(可选)",
|
||||
"on_complete_action_desc": "任务完成时调用 HA 服务 — 例如重置设备上的物理计数器。",
|
||||
"on_complete_action_service": "服务",
|
||||
"on_complete_action_target": "目标实体",
|
||||
"on_complete_action_target_hint": "注意:实体的域必须与服务匹配 — 例如 'button.press' 仅适用于 button.* 实体,'counter.increment' 仅适用于 counter.* 实体等。如果不匹配,动作将静默失败。",
|
||||
"on_complete_action_data": "数据(JSON, 可选)",
|
||||
"on_complete_action_test": "验证配置",
|
||||
"on_complete_action_test_success": "✓ 配置有效(动作将在任务完成时触发)",
|
||||
"on_complete_action_test_failed": "失败",
|
||||
"quick_complete_defaults_title": "快速完成默认值(用于二维码扫描, 可选)",
|
||||
"quick_complete_defaults_desc": "扫描快速完成二维码时使用的预设值。如果不设置,二维码将打开完成对话框。",
|
||||
"quick_complete_defaults_notes": "备注",
|
||||
"quick_complete_defaults_cost": "费用",
|
||||
"quick_complete_defaults_duration": "耗时(分钟)",
|
||||
"quick_complete_defaults_feedback_none": "无反馈",
|
||||
"quick_complete_defaults_feedback_needed": "是必要的",
|
||||
"quick_complete_defaults_feedback_not_needed": "不必要的",
|
||||
"quick_complete_success": "已快速标记为完成",
|
||||
"show_all_objects": "显示所有设备",
|
||||
"show_all_tasks": "清除过滤 — 显示所有任务",
|
||||
"filter_to_overdue": "仅显示超期任务",
|
||||
"filter_to_due_soon": "仅显示即将到期任务",
|
||||
"filter_to_triggered": "仅显示已触发任务",
|
||||
"open_task": "打开任务",
|
||||
"show_details": "显示历史与统计",
|
||||
"hide_details": "隐藏详情",
|
||||
"history_empty": "尚无历史记录。",
|
||||
"history_edit_button": "编辑条目",
|
||||
"total_cost": "总计费用",
|
||||
"times_performed": "执行次数",
|
||||
"older_entries": "更早的条目",
|
||||
"open_in_panel": "在维护面板中打开",
|
||||
"skip_reason": "跳过原因(可选)",
|
||||
"reset_to_date": "重置最后执行日期为",
|
||||
"delete_task_confirm": "确定删除此任务及其历史记录吗?",
|
||||
"delete_object_confirm": "确定删除此设备及其所有任务吗?",
|
||||
"loading": "加载中…",
|
||||
"archive": "归档",
|
||||
"undo": "撤销",
|
||||
"task_archived": "任务已归档",
|
||||
"object_archived": "对象已归档",
|
||||
"unarchive": "取消归档",
|
||||
"archived": "已归档",
|
||||
"show_archived": "显示已归档",
|
||||
"hide_archived": "隐藏已归档",
|
||||
"confirm_archive_object": "归档此对象及其任务?历史记录将保留,以后可恢复。",
|
||||
"settings_archive": "归档与保留",
|
||||
"settings_archive_desc": "将已完成的一次性任务归档而不删除。已归档项目会被隐藏且不再活动,但保留其历史记录和费用。",
|
||||
"settings_archive_oneoff_days": "已完成的一次性任务在多少天后自动归档(0 = 关闭)",
|
||||
"settings_delete_archived_oneoff_days": "已归档的一次性任务在多少天后自动删除(0 = 永不)",
|
||||
"archive_object": "归档对象",
|
||||
"unarchive_object": "取消归档对象",
|
||||
"documents": "文档",
|
||||
"documents_empty": "暂无文档。",
|
||||
"doc_upload": "上传文件",
|
||||
"doc_uploading": "上传中…",
|
||||
"doc_add_link": "添加链接",
|
||||
"doc_link_url": "网址 (https://…)",
|
||||
"doc_link_title": "标题(可选)",
|
||||
"doc_open": "打开",
|
||||
"doc_delete_confirm": "删除“{name}”?",
|
||||
"doc_too_large": "文件过大(最大 25 MB)。",
|
||||
"doc_upload_failed": "上传失败。",
|
||||
"completion_photo_optional": "Completion photo (optional)",
|
||||
"add_photo": "Add photo",
|
||||
"uploading": "Uploading…",
|
||||
"remove": "Remove",
|
||||
"doc_deduped": "已在别处存储——共享,不占用额外空间。",
|
||||
"doc_dup_in_object": "此文件已附加到该对象。",
|
||||
"doc_link_invalid": "仅允许 http/https 链接。",
|
||||
"doc_cat_manual": "手册",
|
||||
"doc_cat_warranty": "保修",
|
||||
"doc_cat_invoice": "发票",
|
||||
"doc_cat_spare_parts": "备件",
|
||||
"doc_cat_photo": "照片",
|
||||
"doc_cat_other": "其他",
|
||||
"doc_link_badge": "链接",
|
||||
"doc_storage_title": "文档存储",
|
||||
"doc_storage_saved": "通过去重节省",
|
||||
"doc_storage_refresh": "刷新",
|
||||
"doc_download": "下载",
|
||||
"doc_close": "关闭",
|
||||
"doc_camera": "拍照",
|
||||
"doc_drop_hint": "将文件拖放到此处",
|
||||
"doc_task_none": "没有与此任务关联的文档。",
|
||||
"doc_link_existing": "关联文档…",
|
||||
"doc_attach": "关联",
|
||||
"doc_unlink": "取消关联",
|
||||
"doc_page": "页码",
|
||||
"chart_range_7d": "7天",
|
||||
"chart_range_30d": "30天",
|
||||
"chart_range_90d": "90天",
|
||||
"chart_range_1y": "1年",
|
||||
"chart_since_service": "自上次维护以来",
|
||||
"chart_no_stats": "此实体没有长期统计数据 — 仅显示维护记录中的数值",
|
||||
"auto_complete_on_recovery": "传感器恢复时自动完成",
|
||||
"auto_complete_on_recovery_help": "当触发条件自行解除时(如已加盐、已更换滤芯),记录一次完成并更新上次执行日期。",
|
||||
"doc_search": "搜索文档…",
|
||||
"doc_search_none": "没有匹配的文档",
|
||||
"link_device_optional": "关联到现有设备(可选)",
|
||||
"parent_object_optional": "父对象(可选)",
|
||||
"parent_none": "(无父对象)",
|
||||
"paused": "已暂停",
|
||||
"pause_object": "暂停",
|
||||
"resume_object": "恢复",
|
||||
"pause_until_prompt": "冻结此对象的日程——在恢复之前不会有任何到期或通知。可选设置自动恢复日期。",
|
||||
"pause_until_label": "恢复日期(可选)",
|
||||
"object_paused": "对象已暂停",
|
||||
"object_resumed": "对象已恢复——日程已重新开始",
|
||||
"object_paused_badge": "已暂停",
|
||||
"paused_until_label": "至",
|
||||
"replace_object": "更换…",
|
||||
"replace_object_prompt": "让此对象退役并创建继任者。历史和费用留在旧对象的归档中;任务和文档转移到新对象,计数器重新开始。",
|
||||
"replace_name_label": "继任者名称",
|
||||
"object_replaced": "对象已更换——已创建继任者",
|
||||
"reading_unit_label": "读数单位(如 kWh、m³)",
|
||||
"reading_unit_help": "完成此任务时显示在记录值旁边。",
|
||||
"reading_value_label": "读数值",
|
||||
"reading_label": "读数",
|
||||
"settings_templates_label": "模板库",
|
||||
"settings_templates_hint": "取消勾选你永远不需要的模板——它们会从\"从模板\"选择器(面板和配置流程)中消失。其他一切不变;可随时重新启用。",
|
||||
"worksheet": "工作单",
|
||||
"worksheet_scan_view": "扫码打开任务",
|
||||
"worksheet_scan_complete": "扫码完成",
|
||||
"worksheet_manual_excerpt": "手册摘录",
|
||||
"worksheet_pages": "页",
|
||||
"worksheet_printed": "打印于",
|
||||
"worksheet_never": "从未"
|
||||
}
|
||||
@@ -0,0 +1,586 @@
|
||||
/** Maintenance Supporter — Calendar Card.
|
||||
*
|
||||
* Standalone Lovelace card extracted from the panel's Calendar tab (v1.5.0+).
|
||||
* All the visuals: 7/14/30/365 day window chips, per-event source icons
|
||||
* (clock vs trending-up), prediction-confidence pill, projected recurrences
|
||||
* at 55% opacity, today-pill highlight, empty-day collapsing in the year view.
|
||||
*
|
||||
* Click on an event fires an ``ll-custom`` event with payload
|
||||
* ``{type: "maintenance-supporter:open-task", entry_id, task_id}``. The
|
||||
* dashboard-strategy bundle's document-level handler picks that up and
|
||||
* either opens the task dialog in-place (preferred) or deep-links into the
|
||||
* panel as a fallback.
|
||||
*
|
||||
* Card config:
|
||||
*
|
||||
* type: custom:maintenance-supporter-calendar-card
|
||||
* title: My maintenance calendar # optional
|
||||
* window_days: 30 # 7 | 14 | 30 | 365 — default 30
|
||||
* show_window_chips: true # default true; hide for embedded use
|
||||
* show_user_filter: true # default true
|
||||
* user_filter: "" # "" | "current_user" | "<uuid>"
|
||||
*/
|
||||
|
||||
import { LitElement, html, css, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import {
|
||||
buildCalendarBuckets,
|
||||
buildPastBuckets,
|
||||
isoDateLocal,
|
||||
type CalendarEvent,
|
||||
} from "./helpers/calendar-bucket";
|
||||
import { calendarStyles } from "./calendar-styles";
|
||||
import { sharedStyles, DEFAULT_CURRENCY_SYMBOL, t, ensureLocale, isLocaleLoaded } from "./styles";
|
||||
import type {
|
||||
HomeAssistant,
|
||||
MaintenanceObjectResponse,
|
||||
StatisticsResponse,
|
||||
} from "./types";
|
||||
|
||||
type WindowDays = 7 | 14 | 30 | 365;
|
||||
type PastDays = 30 | 90;
|
||||
|
||||
interface CalendarCardConfig {
|
||||
type: string;
|
||||
title?: string;
|
||||
window_days?: WindowDays;
|
||||
show_window_chips?: boolean;
|
||||
show_user_filter?: boolean;
|
||||
user_filter?: string;
|
||||
/** v2.2.0 — when set, the card renders past N days from history instead
|
||||
* of forward N days from next_due. Mutually exclusive with window_days. */
|
||||
past_days?: PastDays;
|
||||
}
|
||||
|
||||
export class MaintenanceCalendarCard extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@state() private _config: CalendarCardConfig = {
|
||||
type: "custom:maintenance-supporter-calendar-card",
|
||||
};
|
||||
@state() private _objects: MaintenanceObjectResponse[] = [];
|
||||
@state() private _stats: StatisticsResponse | null = null;
|
||||
@state() private _windowDays: WindowDays = 30;
|
||||
@state() private _pastDays: PastDays | 0 = 0; // 0 = forward mode
|
||||
@state() private _userFilter = "";
|
||||
@state() private _unsub: (() => void) | null = null;
|
||||
|
||||
private _dataLoaded = false;
|
||||
private _lastConnection: unknown = null;
|
||||
|
||||
static getConfigElement() {
|
||||
return document.createElement("maintenance-supporter-calendar-card-editor");
|
||||
}
|
||||
|
||||
static getStubConfig() {
|
||||
// Opinionated default: 30-day rolling window, both controls visible.
|
||||
return {
|
||||
type: "custom:maintenance-supporter-calendar-card",
|
||||
window_days: 30,
|
||||
show_window_chips: true,
|
||||
show_user_filter: true,
|
||||
};
|
||||
}
|
||||
|
||||
setConfig(config: CalendarCardConfig): void {
|
||||
this._config = { ...config };
|
||||
if (config.past_days && [30, 90].includes(config.past_days)) {
|
||||
this._pastDays = config.past_days as PastDays;
|
||||
} else if (config.window_days && [7, 14, 30, 365].includes(config.window_days)) {
|
||||
this._windowDays = config.window_days as WindowDays;
|
||||
this._pastDays = 0;
|
||||
}
|
||||
if (typeof config.user_filter === "string") {
|
||||
this._userFilter = config.user_filter;
|
||||
}
|
||||
}
|
||||
|
||||
getCardSize(): number {
|
||||
return 6;
|
||||
}
|
||||
|
||||
private get _lang(): string {
|
||||
return this.hass?.language || "en";
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
if (this._unsub) {
|
||||
try { this._unsub(); } catch { /* ignore */ }
|
||||
this._unsub = null;
|
||||
}
|
||||
this._dataLoaded = false;
|
||||
this._lastConnection = null;
|
||||
}
|
||||
|
||||
updated(changedProps: Map<string, unknown>): void {
|
||||
super.updated(changedProps);
|
||||
const lang = this.hass?.language;
|
||||
if (lang && !isLocaleLoaded(lang)) {
|
||||
ensureLocale(lang).then(() => this.requestUpdate());
|
||||
}
|
||||
if (changedProps.has("hass") && this.hass) {
|
||||
if (!this._dataLoaded) {
|
||||
this._dataLoaded = true;
|
||||
this._lastConnection = this.hass.connection;
|
||||
this._loadData();
|
||||
this._subscribe();
|
||||
} else if (this.hass.connection !== this._lastConnection) {
|
||||
this._lastConnection = this.hass.connection;
|
||||
if (this._unsub) {
|
||||
try { this._unsub(); } catch { /* ignore */ }
|
||||
this._unsub = null;
|
||||
}
|
||||
this._subscribe();
|
||||
this._loadData();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async _loadData(): Promise<void> {
|
||||
try {
|
||||
const [objResult, statsResult] = await Promise.all([
|
||||
this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/objects",
|
||||
}),
|
||||
this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/statistics",
|
||||
}),
|
||||
]);
|
||||
this._objects = (objResult as { objects: MaintenanceObjectResponse[] }).objects;
|
||||
this._stats = statsResult as StatisticsResponse;
|
||||
} catch {
|
||||
// WS not available yet
|
||||
}
|
||||
}
|
||||
|
||||
private async _subscribe(): Promise<void> {
|
||||
try {
|
||||
const unsub = await this.hass.connection.subscribeMessage(
|
||||
(msg: unknown) => {
|
||||
const data = msg as { objects: MaintenanceObjectResponse[] };
|
||||
this._objects = data.objects;
|
||||
},
|
||||
{ type: "maintenance_supporter/subscribe" },
|
||||
);
|
||||
// Detached mid-subscribe → drop the orphaned subscription.
|
||||
if (!this.isConnected) {
|
||||
unsub();
|
||||
return;
|
||||
}
|
||||
this._unsub = unsub;
|
||||
} catch {
|
||||
// Subscription failed
|
||||
}
|
||||
}
|
||||
|
||||
private _onEventClick(ev: CalendarEvent): void {
|
||||
// Past events carry a history_timestamp — those open the history-edit
|
||||
// dialog instead of the task editor. Future / next_due events open
|
||||
// the task editor as usual.
|
||||
if (ev.history_timestamp) {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("ll-custom", {
|
||||
detail: {
|
||||
type: "maintenance-supporter:edit-history",
|
||||
entry_id: ev.entry_id,
|
||||
task_id: ev.task_id,
|
||||
original_timestamp: ev.history_timestamp,
|
||||
},
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("ll-custom", {
|
||||
detail: {
|
||||
type: "maintenance-supporter:open-task",
|
||||
entry_id: ev.entry_id,
|
||||
task_id: ev.task_id,
|
||||
},
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.hass) return nothing;
|
||||
|
||||
const L = this._lang;
|
||||
const showChips = this._config.show_window_chips !== false;
|
||||
const showUserFilter = this._config.show_user_filter !== false;
|
||||
const title = this._config.title;
|
||||
|
||||
let userFilter: string | null = null;
|
||||
if (this._userFilter) {
|
||||
// No UserService here (lives in panel only) — current_user resolves
|
||||
// via hass.user when the user picks "my tasks".
|
||||
userFilter = this._userFilter === "current_user"
|
||||
? (this.hass?.user?.id ?? null)
|
||||
: this._userFilter;
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const isPast = this._pastDays > 0;
|
||||
const buckets = isPast
|
||||
? buildPastBuckets(this._objects, today, this._pastDays, userFilter)
|
||||
: buildCalendarBuckets(this._objects, today, this._windowDays, userFilter);
|
||||
|
||||
const todayIso = isoDateLocal(today);
|
||||
// Year view AND past views collapse empty days — past windows can be
|
||||
// sparse (most days have no maintenance recorded), so listing "no events"
|
||||
// for 30 rows is noise.
|
||||
const hideEmptyDays = this._windowDays === 365 || isPast;
|
||||
const visibleBuckets = hideEmptyDays
|
||||
? buckets.filter((b) => b.events.length > 0)
|
||||
: buckets;
|
||||
|
||||
const renderEvent = (ev: CalendarEvent) => {
|
||||
const statusClass = `cal-status-${ev.status}`;
|
||||
const projClass = ev.projected ? "cal-event-projected" : "";
|
||||
const overdueLabel = ev.status === "overdue" && ev.days_until_due != null
|
||||
? ` (${Math.abs(ev.days_until_due)}d ${t("overdue", L).toLowerCase()})`
|
||||
: "";
|
||||
const recurEvery = ev.projected && ev.interval_days
|
||||
? html`<span class="cal-event-recur">${
|
||||
ev.interval_unit && ev.interval_unit !== "days"
|
||||
? `${ev.interval_days} ${t("unit_" + ev.interval_unit, L)}`
|
||||
: t("cal_every_n_days", L).replace("{n}", String(ev.interval_days))
|
||||
}</span>`
|
||||
: nothing;
|
||||
const isSensor = ev.schedule_type === "sensor_based";
|
||||
const sourceIcon = isSensor
|
||||
? html`<ha-icon class="cal-event-icon cal-source-sensor"
|
||||
title="${t("cal_source_sensor", L)}" icon="mdi:trending-up"></ha-icon>`
|
||||
: html`<ha-icon class="cal-event-icon cal-source-time"
|
||||
title="${ev.adaptive_enabled ? t("cal_source_time_adaptive", L) : t("cal_source_time", L)}"
|
||||
icon="${ev.adaptive_enabled ? "mdi:clock-time-four-outline" : "mdi:clock-outline"}"></ha-icon>`;
|
||||
const predictionSubtitle = isSensor && ev.prediction_confidence
|
||||
&& ev.status !== "triggered" && !ev.projected
|
||||
? html`<span class="cal-event-prediction cal-conf-${ev.prediction_confidence}">
|
||||
${t("cal_predicted", L)} · ${t(`cal_confidence_${ev.prediction_confidence}`, L)}
|
||||
</span>`
|
||||
: nothing;
|
||||
const currencySymbol = this._stats?.budget?.currency_symbol || DEFAULT_CURRENCY_SYMBOL;
|
||||
// Past mode: ev.history_type is the actual event ('completed', 'skipped',
|
||||
// 'reset', 'triggered', 'trigger_replaced'). Showing the derived status
|
||||
// ('OK' for a completion) was confusing — discussion #49 user-feedback:
|
||||
// *"im Panel sind die alten Auslösungen drin nicht die completion oder
|
||||
// skips"* — completion events looked like they hadn't happened because
|
||||
// the badge said "OK" (current task status) not "Completed" (the
|
||||
// actual past event). In past mode we now label with the event type.
|
||||
// Forward mode still uses the projected status (overdue/due_soon/etc).
|
||||
const badgeLabel = ev.history_type
|
||||
? t(ev.history_type, L)
|
||||
: t(ev.status, L);
|
||||
return html`
|
||||
<div class="cal-event ${projClass}"
|
||||
@click=${() => this._onEventClick(ev)}>
|
||||
${sourceIcon}
|
||||
<span class="cal-status-pill ${statusClass}">${badgeLabel}</span>
|
||||
<div class="cal-event-body">
|
||||
<div class="cal-event-title">${ev.object_name} · ${ev.task_name}${overdueLabel}</div>
|
||||
${predictionSubtitle}
|
||||
${recurEvery}
|
||||
</div>
|
||||
${ev.avg_cost != null && ev.avg_cost > 0
|
||||
? html`<span class="cal-event-cost">${ev.avg_cost.toFixed(0)} ${currencySymbol}</span>`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
const renderDayRow = (bucket: { date: string; events: CalendarEvent[] }) => {
|
||||
const [y, m, d] = bucket.date.split("-").map(Number);
|
||||
const date = new Date(y, m - 1, d);
|
||||
const isToday = bucket.date === todayIso;
|
||||
const weekday = date.toLocaleDateString(L, { weekday: "short" });
|
||||
const monthLabel = date.toLocaleDateString(L, { month: "long" });
|
||||
return html`
|
||||
<div class="cal-day-row">
|
||||
<div class="cal-day-pill ${isToday ? "cal-today" : ""}">
|
||||
<span class="cal-pill-weekday">${weekday}</span>
|
||||
<span class="cal-pill-day">${date.getDate()}</span>
|
||||
</div>
|
||||
<div class="cal-day-content">
|
||||
<div class="cal-day-header">
|
||||
<span class="cal-day-month">${monthLabel}</span>
|
||||
${isToday ? html`<span class="cal-day-today-badge">${t("today", L)}</span>` : nothing}
|
||||
</div>
|
||||
${bucket.events.length === 0
|
||||
? html`<div class="cal-empty">${t("cal_no_events", L)}</div>`
|
||||
: bucket.events.map(renderEvent)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
return html`
|
||||
<ha-card .header=${title}>
|
||||
${showChips || showUserFilter
|
||||
? html`
|
||||
<div class="cal-controls">
|
||||
${showChips
|
||||
? html`
|
||||
<div class="cal-window-chips cal-past-chips" title="${t("cal_past_windows", L) || "Past windows"}">
|
||||
${[30, 90].map((p) => html`
|
||||
<button class="cal-window-chip cal-past-chip ${this._pastDays === p ? "active" : ""}"
|
||||
@click=${() => {
|
||||
this._pastDays = p as PastDays;
|
||||
}}>
|
||||
−${p}d
|
||||
</button>
|
||||
`)}
|
||||
</div>
|
||||
<span class="cal-chip-separator" aria-hidden="true">●</span>
|
||||
<div class="cal-window-chips" title="${t("cal_forward_windows", L) || "Forward windows"}">
|
||||
${[7, 14, 30, 365].map((w) => html`
|
||||
<button class="cal-window-chip ${this._pastDays === 0 && this._windowDays === w ? "active" : ""}"
|
||||
@click=${() => {
|
||||
this._windowDays = w as WindowDays;
|
||||
this._pastDays = 0;
|
||||
}}>
|
||||
${w === 365 ? "+1y" : `+${w}d`}
|
||||
</button>
|
||||
`)}
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
${showUserFilter
|
||||
? html`
|
||||
<select class="cal-user-filter"
|
||||
.value=${this._userFilter}
|
||||
@change=${(e: Event) => {
|
||||
this._userFilter = (e.target as HTMLSelectElement).value;
|
||||
}}>
|
||||
<option value="">${t("all_users", L)}</option>
|
||||
<option value="current_user">${t("my_tasks", L)}</option>
|
||||
</select>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
<div class="cal-rolling">
|
||||
${visibleBuckets.length === 0 && hideEmptyDays
|
||||
? html`<div class="cal-empty">${t("cal_no_events", L)}</div>`
|
||||
: visibleBuckets.map(renderDayRow)}
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = [
|
||||
sharedStyles,
|
||||
calendarStyles,
|
||||
css`
|
||||
:host { display: block; }
|
||||
ha-card { padding: 0; overflow: hidden; }
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
// ── Calendar Card Editor ────────────────────────────────────────────────────
|
||||
//
|
||||
// Visual config: pick window_days from a dropdown, toggle the in-card chips
|
||||
// and user-filter on/off. Same pattern as MaintenanceSupporterCardEditor —
|
||||
// LitElement with setConfig + dispatched config-changed.
|
||||
|
||||
const WINDOW_DAY_OPTIONS: Array<{ value: WindowDays; label: string }> = [
|
||||
{ value: 7, label: "Week (7 days)" },
|
||||
{ value: 14, label: "Fortnight (14 days)" },
|
||||
{ value: 30, label: "Month (30 days, default)" },
|
||||
{ value: 365, label: "Year (365 days, empty days collapsed)" },
|
||||
];
|
||||
|
||||
class MaintenanceCalendarCardEditor extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@state() private _config: CalendarCardConfig = {
|
||||
type: "custom:maintenance-supporter-calendar-card",
|
||||
};
|
||||
|
||||
setConfig(config: CalendarCardConfig): void {
|
||||
this._config = { ...config };
|
||||
}
|
||||
|
||||
private _valueChanged(key: keyof CalendarCardConfig, value: unknown): void {
|
||||
const newConfig = { ...this._config, [key]: value } as CalendarCardConfig;
|
||||
// Drop default-equivalent values so saved YAML stays minimal
|
||||
if (key === "show_window_chips" && value === true) {
|
||||
delete (newConfig as unknown as Record<string, unknown>).show_window_chips;
|
||||
}
|
||||
if (key === "show_user_filter" && value === true) {
|
||||
delete (newConfig as unknown as Record<string, unknown>).show_user_filter;
|
||||
}
|
||||
if (key === "title" && (!value || (typeof value === "string" && value.trim() === ""))) {
|
||||
delete (newConfig as unknown as Record<string, unknown>).title;
|
||||
}
|
||||
if (key === "user_filter" && value === "") {
|
||||
delete (newConfig as unknown as Record<string, unknown>).user_filter;
|
||||
}
|
||||
this._config = newConfig;
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("config-changed", {
|
||||
detail: { config: newConfig },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const currentWindow = this._config.window_days ?? 30;
|
||||
const showChips = this._config.show_window_chips !== false;
|
||||
const showUserFilter = this._config.show_user_filter !== false;
|
||||
const userFilter = this._config.user_filter ?? "";
|
||||
const title = this._config.title ?? "";
|
||||
return html`
|
||||
<div class="editor">
|
||||
<div class="row">
|
||||
<label for="title">Title (optional)</label>
|
||||
<input
|
||||
id="title"
|
||||
type="text"
|
||||
.value=${title}
|
||||
@input=${(e: Event) =>
|
||||
this._valueChanged("title", (e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="window">Default window</label>
|
||||
<select
|
||||
id="window"
|
||||
@change=${(e: Event) =>
|
||||
this._valueChanged(
|
||||
"window_days",
|
||||
Number((e.target as HTMLSelectElement).value) as WindowDays,
|
||||
)}
|
||||
>
|
||||
${WINDOW_DAY_OPTIONS.map(
|
||||
(o) =>
|
||||
html`<option value="${o.value}" ?selected=${o.value === currentWindow}>${o.label}</option>`,
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="row toggle">
|
||||
<label for="chips">Show window chips inside the card</label>
|
||||
<input
|
||||
id="chips"
|
||||
type="checkbox"
|
||||
.checked=${showChips}
|
||||
@change=${(e: Event) =>
|
||||
this._valueChanged(
|
||||
"show_window_chips",
|
||||
(e.target as HTMLInputElement).checked,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div class="hint">
|
||||
Hide the chips when the card is embedded in a strategy view that
|
||||
already serves as the window selector.
|
||||
</div>
|
||||
<div class="row toggle">
|
||||
<label for="userf">Show user filter dropdown</label>
|
||||
<input
|
||||
id="userf"
|
||||
type="checkbox"
|
||||
.checked=${showUserFilter}
|
||||
@change=${(e: Event) =>
|
||||
this._valueChanged(
|
||||
"show_user_filter",
|
||||
(e.target as HTMLInputElement).checked,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="userv">Default user filter</label>
|
||||
<select
|
||||
id="userv"
|
||||
@change=${(e: Event) =>
|
||||
this._valueChanged(
|
||||
"user_filter",
|
||||
(e.target as HTMLSelectElement).value,
|
||||
)}
|
||||
>
|
||||
<option value="" ?selected=${userFilter === ""}>All users</option>
|
||||
<option value="current_user" ?selected=${userFilter === "current_user"}>
|
||||
My tasks (current user)
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host { display: block; padding: 8px 0; }
|
||||
.editor { display: flex; flex-direction: column; gap: 12px; }
|
||||
.row { display: flex; flex-direction: column; gap: 4px; }
|
||||
.row.toggle {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
label { font-weight: 500; color: var(--primary-text-color); font-size: 14px; }
|
||||
input[type="text"], select {
|
||||
padding: 8px;
|
||||
font-size: 14px;
|
||||
background: var(--card-background-color, white);
|
||||
color: var(--primary-text-color, black);
|
||||
border: 1px solid var(--divider-color, #e0e0e0);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.hint {
|
||||
margin-top: -4px;
|
||||
font-size: 12px;
|
||||
color: var(--secondary-text-color, #666);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
if (!customElements.get("maintenance-supporter-calendar-card")) {
|
||||
customElements.define(
|
||||
"maintenance-supporter-calendar-card",
|
||||
MaintenanceCalendarCard,
|
||||
);
|
||||
}
|
||||
if (!customElements.get("maintenance-supporter-calendar-card-editor")) {
|
||||
customElements.define(
|
||||
"maintenance-supporter-calendar-card-editor",
|
||||
MaintenanceCalendarCardEditor,
|
||||
);
|
||||
}
|
||||
|
||||
// Register with HACS / customCards so the picker shows it
|
||||
const w = window as unknown as {
|
||||
customCards?: Array<{
|
||||
type: string;
|
||||
name: string;
|
||||
description: string;
|
||||
preview?: boolean;
|
||||
}>;
|
||||
};
|
||||
w.customCards = w.customCards || [];
|
||||
// HA's custom-card resolver maps ``custom:X`` → element tag ``X``. Our element
|
||||
// is registered as ``maintenance-supporter-calendar-card``, so the customCards
|
||||
// type MUST match that exact suffix or the card-picker entry resolves to a
|
||||
// non-existent element and the strategy's calendar mode throws a config error.
|
||||
const CALENDAR_CARD_TYPE = "maintenance-supporter-calendar-card";
|
||||
const alreadyRegistered = w.customCards.some(
|
||||
(c) => c.type === CALENDAR_CARD_TYPE,
|
||||
);
|
||||
if (!alreadyRegistered) {
|
||||
w.customCards.push({
|
||||
type: CALENDAR_CARD_TYPE,
|
||||
name: "Maintenance Supporter — Calendar",
|
||||
description:
|
||||
"Rolling calendar of maintenance tasks with 7/14/30/365 day windows, source icons, and prediction-confidence pills.",
|
||||
preview: true,
|
||||
});
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,278 @@
|
||||
/** Card editor for the Maintenance Supporter Lovelace card. */
|
||||
|
||||
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";
|
||||
|
||||
const STATUS_KEYS = ["overdue", "triggered", "due_soon", "ok"] as const;
|
||||
|
||||
export class MaintenanceSupporterCardEditor extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@state() private _config: CardConfig = { type: "custom:maintenance-supporter-card" };
|
||||
@state() private _objects: MaintenanceObjectResponse[] = [];
|
||||
@state() private _loadingObjects = true;
|
||||
@state() private _loadError = false;
|
||||
|
||||
private _objectsLoaded = false;
|
||||
|
||||
private get _lang(): string {
|
||||
return this.hass?.language || "en";
|
||||
}
|
||||
|
||||
setConfig(config: CardConfig): void {
|
||||
this._config = { ...config };
|
||||
}
|
||||
|
||||
updated(changedProps: Map<string, unknown>): void {
|
||||
super.updated(changedProps);
|
||||
if (changedProps.has("hass") && this.hass && !this._objectsLoaded) {
|
||||
this._objectsLoaded = true;
|
||||
this._loadObjects();
|
||||
}
|
||||
}
|
||||
|
||||
private async _loadObjects(): Promise<void> {
|
||||
try {
|
||||
const result = await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/objects",
|
||||
}) as { objects: MaintenanceObjectResponse[] };
|
||||
this._objects = result.objects || [];
|
||||
this._loadError = false;
|
||||
} catch {
|
||||
this._objects = [];
|
||||
this._loadError = true;
|
||||
}
|
||||
this._loadingObjects = false;
|
||||
}
|
||||
|
||||
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) {
|
||||
delete (newConfig as Record<string, unknown>)[key];
|
||||
}
|
||||
this._config = newConfig;
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("config-changed", { detail: { config: newConfig } })
|
||||
);
|
||||
}
|
||||
|
||||
private _toggleStatus(status: string, on: boolean): void {
|
||||
const current = new Set(this._config.filter_status || []);
|
||||
if (on) current.add(status); else current.delete(status);
|
||||
this._valueChanged("filter_status", [...current]);
|
||||
}
|
||||
|
||||
private _toggleObject(name: string, on: boolean): void {
|
||||
const current = new Set(this._config.filter_objects || []);
|
||||
if (on) current.add(name); else current.delete(name);
|
||||
this._valueChanged("filter_objects", [...current]);
|
||||
}
|
||||
|
||||
private _onEntitiesChanged = (e: CustomEvent<{ value: string[] }>): void => {
|
||||
this._valueChanged("entity_ids", e.detail.value || []);
|
||||
};
|
||||
|
||||
render() {
|
||||
const L = this._lang;
|
||||
const selectedStatuses = new Set(this._config.filter_status || []);
|
||||
const selectedObjects = new Set(this._config.filter_objects || []);
|
||||
const objectNames = [...this._objects]
|
||||
.map((o) => o.object.name)
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
// Build the list of OUR sensor + binary_sensor entity_ids so the
|
||||
// ha-entities-picker only shows maintenance_supporter entities, not
|
||||
// every sensor in HA.
|
||||
const ourEntities: string[] = [];
|
||||
for (const o of this._objects) {
|
||||
for (const t of o.tasks) {
|
||||
if (t.sensor_entity_id) ourEntities.push(t.sensor_entity_id);
|
||||
if (t.binary_sensor_entity_id) ourEntities.push(t.binary_sensor_entity_id);
|
||||
}
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="editor">
|
||||
<ha-textfield
|
||||
label="${t("card_title", L)}"
|
||||
.value=${this._config.title || ""}
|
||||
@input=${(e: Event) =>
|
||||
this._valueChanged("title", (e.target as HTMLInputElement).value)}
|
||||
></ha-textfield>
|
||||
|
||||
<!-- Status filter (chip row) -->
|
||||
<div class="field">
|
||||
<div class="field-label">${t("card_filter_status", L)}</div>
|
||||
<div class="chip-row">
|
||||
${STATUS_KEYS.map((s) => html`
|
||||
<label class="chip ${selectedStatuses.has(s) ? "active" : ""}">
|
||||
<input type="checkbox"
|
||||
.checked=${selectedStatuses.has(s)}
|
||||
@change=${(e: Event) => this._toggleStatus(s, (e.target as HTMLInputElement).checked)} />
|
||||
${t(s, L)}
|
||||
</label>
|
||||
`)}
|
||||
</div>
|
||||
<div class="field-help">${t("card_filter_status_help", L)}</div>
|
||||
</div>
|
||||
|
||||
<!-- Object filter (multi-checkbox) -->
|
||||
<div class="field">
|
||||
<div class="field-label">${t("card_filter_objects", L)}</div>
|
||||
${this._loadingObjects
|
||||
? html`<div class="field-help">${t("card_loading_objects", L)}</div>`
|
||||
: this._loadError
|
||||
? html`<div class="field-help error-text">${t("card_load_error", L)}</div>`
|
||||
: objectNames.length === 0
|
||||
? html`<div class="field-help">${t("no_objects", L)}</div>`
|
||||
: html`
|
||||
<div class="object-list">
|
||||
${objectNames.map((name) => html`
|
||||
<label class="object-row">
|
||||
<input type="checkbox"
|
||||
.checked=${selectedObjects.has(name)}
|
||||
@change=${(e: Event) => this._toggleObject(name, (e.target as HTMLInputElement).checked)} />
|
||||
<span>${name}</span>
|
||||
</label>
|
||||
`)}
|
||||
</div>
|
||||
<div class="field-help">${t("card_filter_objects_help", L)}</div>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Entity-id filter (HA-native pattern). Limited to our integration's
|
||||
sensor + binary_sensor entities via includeEntities so the picker
|
||||
stays usable on installs with thousands of entities. -->
|
||||
<div class="field">
|
||||
<div class="field-label">${t("card_filter_entities", L)}</div>
|
||||
<ha-entities-picker
|
||||
.hass=${this.hass}
|
||||
.value=${this._config.entity_ids || []}
|
||||
.includeDomains=${["sensor", "binary_sensor"]}
|
||||
.includeEntities=${ourEntities}
|
||||
@value-changed=${this._onEntitiesChanged}
|
||||
></ha-entities-picker>
|
||||
<div class="field-help">${t("card_filter_entities_help", L)}</div>
|
||||
</div>
|
||||
|
||||
<ha-formfield label="${t("card_show_header", L)}">
|
||||
<ha-switch
|
||||
.checked=${this._config.show_header !== false}
|
||||
@change=${(e: Event) =>
|
||||
this._valueChanged("show_header", (e.target as HTMLInputElement).checked)}
|
||||
></ha-switch>
|
||||
</ha-formfield>
|
||||
|
||||
<ha-formfield label="${t("card_show_actions", L)}">
|
||||
<ha-switch
|
||||
.checked=${this._config.show_actions !== false}
|
||||
@change=${(e: Event) =>
|
||||
this._valueChanged("show_actions", (e.target as HTMLInputElement).checked)}
|
||||
></ha-switch>
|
||||
</ha-formfield>
|
||||
|
||||
<ha-formfield label="${t("card_compact", L)}">
|
||||
<ha-switch
|
||||
.checked=${this._config.compact || false}
|
||||
@change=${(e: Event) =>
|
||||
this._valueChanged("compact", (e.target as HTMLInputElement).checked)}
|
||||
></ha-switch>
|
||||
</ha-formfield>
|
||||
|
||||
<ha-textfield
|
||||
label="${t("card_max_items", L)}"
|
||||
type="number"
|
||||
.value=${String(this._config.max_items || 0)}
|
||||
@input=${(e: Event) =>
|
||||
this._valueChanged("max_items", parseInt((e.target as HTMLInputElement).value, 10) || 0)}
|
||||
></ha-textfield>
|
||||
${nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
.editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
ha-textfield { display: block; }
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.field-label {
|
||||
font-size: 13px;
|
||||
color: var(--secondary-text-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
.field-help {
|
||||
font-size: 12px;
|
||||
color: var(--secondary-text-color);
|
||||
font-style: italic;
|
||||
}
|
||||
.field-help.error-text {
|
||||
color: var(--error-color, #f44336);
|
||||
font-style: normal;
|
||||
}
|
||||
.chip-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: 14px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
user-select: none;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.chip:hover {
|
||||
background: var(--secondary-background-color);
|
||||
}
|
||||
.chip.active {
|
||||
background: var(--primary-color);
|
||||
color: var(--text-primary-color);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
.chip input {
|
||||
display: none;
|
||||
}
|
||||
.object-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 4px 12px;
|
||||
padding: 6px 0;
|
||||
}
|
||||
.object-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 0;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.object-row input { cursor: pointer; }
|
||||
`;
|
||||
}
|
||||
|
||||
// Module-bottom registration so esbuild's tree-shaker doesn't drop the
|
||||
// element class when the only reference looks "type-only" (the
|
||||
// @customElement decorator pattern triggered exactly that bug for the
|
||||
// dialog components — same fix applied here for the card editor).
|
||||
if (!customElements.get("maintenance-supporter-card-editor")) {
|
||||
customElements.define(
|
||||
"maintenance-supporter-card-editor",
|
||||
MaintenanceSupporterCardEditor,
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user