217 files

This commit is contained in:
Home Assistant Version Control
2026-07-30 23:59:38 +00:00
parent d43a63ad29
commit 7b5e46e702
217 changed files with 15978 additions and 3912 deletions
@@ -11,18 +11,14 @@ 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;
}
import type { BudgetStatus, HomeAssistant } from "../types";
interface CardConfig { type: string; title?: string; }
/** Backend default for budget_alert_threshold (const.py / settings_registry).
* Only reached if an older core omits the field from budget_status. */
const DEFAULT_ALERT_THRESHOLD_PCT = 80;
export class MaintenanceBudgetSectionCard extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _config: CardConfig = { type: "" };
@@ -109,10 +105,21 @@ export class MaintenanceBudgetSectionCard extends LitElement {
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";
// The amber step is the CONFIGURED budget_alert_threshold, not a literal 80
// — same rule the panel's budget bar uses (maintenance-panel._renderBudgetBar).
const threshold = s.alert_threshold_pct ?? DEFAULT_ALERT_THRESHOLD_PCT;
const tracks = [
{
label: t("budget_monthly", L) || "Monthly",
spent: s.monthly_spent || 0,
budget: s.monthly_budget || 0,
},
{
label: t("budget_yearly", L) || "Yearly",
spent: s.yearly_spent || 0,
budget: s.yearly_budget || 0,
},
];
return html`
<ha-card>
@@ -127,25 +134,34 @@ export class MaintenanceBudgetSectionCard extends LitElement {
${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>
${tracks.map((track) => {
// #104: budget tracking without a maximum — a bar needs a
// denominator, so show the plain spent total instead of "9 / 0 €"
// over an always-empty bar. Mirrors the panel's spent-only lines.
if (!(track.budget > 0)) {
return html`
<div class="track spent-only">
<div class="track-label-row">
<label>${track.label}</label>
<span class="track-numbers ok">${track.spent.toFixed(0)} ${sym}</span>
</div>
</div>
`;
}
const pct = Math.min(100, Math.max(0, (track.spent / track.budget) * 100));
const warn = pct >= 100 ? "danger" : pct >= threshold ? "warning" : "ok";
return html`
<div class="track">
<div class="track-label-row">
<label>${track.label}</label>
<span class="track-numbers ${warn}">
${track.spent.toFixed(0)} / ${track.budget.toFixed(0)} ${sym}
</span>
</div>
<div class="bar"><div class="bar-fill ${warn}" style="width:${pct}%"></div></div>
</div>
`;
})}
${this._isAdmin
? html`
@@ -2,9 +2,11 @@
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import type { HomeAssistant } from "../types";
import type { HomeAssistant, TaskPartLink } from "../types";
import { t, nativeFieldStyles } from "../styles";
import { describeWsError } from "../ws-errors";
import { partLinkKey, type LinkedPart } from "../helpers/shared-parts";
import { REQUIRED_COMPLETION_LABELS } from "./required-completion-labels";
export class MaintenanceCompleteDialog extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -19,12 +21,20 @@ export class MaintenanceCompleteDialog extends LitElement {
@property() public readingUnit = "";
/** Buy task (part_ref): default restock quantity — shows an editable qty field. */
@property({ attribute: false }) public restockDefault: number | null = null;
/** #99: the object's parts — enables the editable "parts used" section. */
@property({ attribute: false }) public parts: Array<{ id: string; name: string; unit?: string | null; stock?: number | null }> = [];
/** #99: the task's fixed consumes_parts links (prefill for the section). */
@property({ attribute: false }) public consumesParts: Array<{ part_id: string; quantity: number }> = [];
/** #99: the parts offered on completion — enables the editable "parts used"
* section. Built by `partsForCompletion`: the object's own inventory plus
* every shared pool this task links to (#111), each tagged with its owner. */
@property({ attribute: false }) public parts: LinkedPart[] = [];
/** #99: the task's fixed consumes_parts links (prefill for the section).
* A link may carry an `entry_id` (#111) and MUST keep it through the edit —
* without it the completion would decrement the wrong inventory, or none. */
@property({ attribute: false }) public consumesParts: TaskPartLink[] = [];
/** "Consumes: 1× HEPA-Filter (Shelf B)" hint lines for consuming tasks. */
@property({ type: Array }) public consumesInfo: string[] = [];
/** Details this task demands before it counts as done (v2.44). The backend
* enforces the same list at every completion surface; blocking Save here
* just means the user never has to meet that rejection. */
@property({ type: Array }) public requiredFields: string[] = [];
@state() private _open = false;
@state() private _notes = "";
@state() private _cost = "";
@@ -38,7 +48,9 @@ export class MaintenanceCompleteDialog extends LitElement {
@state() private _photoUploading = false;
@state() private _readingValue = "";
@state() private _restockQty = "";
@state() private _usedParts: Record<string, number> = {};
/** Keyed by `partLinkKey` — the (entry_id, part_id) pair — because two
* objects can carry the same part id, so part_id alone would merge pools. */
@state() private _usedParts: Record<string, TaskPartLink> = {};
public open(): void {
if (this._open) return;
@@ -55,8 +67,9 @@ export class MaintenanceCompleteDialog extends LitElement {
this._readingValue = "";
this._restockQty = this.restockDefault !== null ? String(this.restockDefault) : "";
// #99: prefill "parts used" with the task's fixed links — the user can
// untick or adjust before completing.
this._usedParts = Object.fromEntries(this.consumesParts.map((l) => [l.part_id, l.quantity]));
// untick or adjust before completing. The whole link is kept, entry_id
// included, so a shared pool survives the edit (#111).
this._usedParts = Object.fromEntries(this.consumesParts.map((l) => [partLinkKey(l), { ...l }]));
}
private _toggleCheck(idx: number): void {
@@ -149,10 +162,16 @@ export class MaintenanceCompleteDialog extends LitElement {
}
// #99: with a parts section shown, send the explicit selection — it
// replaces the automatic consumes_parts deduction (empty = none used).
// entry_id travels only when the pool is somebody else's (#111), so an
// own-part payload is byte-identical to what shipped before.
if (this.parts.length > 0) {
data.used_parts = Object.entries(this._usedParts)
.filter(([, qty]) => Number.isFinite(qty) && qty > 0)
.map(([part_id, quantity]) => ({ part_id, quantity }));
data.used_parts = Object.values(this._usedParts)
.filter((l) => Number.isFinite(l.quantity) && l.quantity > 0)
.map((l) =>
l.entry_id
? { part_id: l.part_id, quantity: l.quantity, entry_id: l.entry_id }
: { part_id: l.part_id, quantity: l.quantity },
);
}
await this.hass.connection.sendMessagePromise(data);
this._open = false;
@@ -164,6 +183,28 @@ export class MaintenanceCompleteDialog extends LitElement {
}
}
/** Required details the user has not supplied yet (drives Save + markers). */
private get _missingRequired(): string[] {
const filled: Record<string, boolean> = {
notes: this._notes.trim() !== "",
cost: this._cost.trim() !== "",
duration: this._duration.trim() !== "",
photo: this._photoDocId !== "",
// "Who did it" is filled in server-side from the authenticated
// connection (websocket/tasks_actions.py), so the dialog satisfies it
// as long as we ARE a logged-in user. Claiming it is always satisfied
// was how a task requiring "user" ended up unclosable: Save stayed
// enabled and the backend rejected the completion every time.
user: !!this.hass?.user,
};
return this.requiredFields.filter((f) => !filled[f]);
}
/** Marker appended to a required field's label. */
private _req(field: string) {
return this.requiredFields.includes(field) ? html`<span class="req-mark" aria-hidden="true">*</span>` : nothing;
}
private _close(): void {
this._open = false;
}
@@ -200,25 +241,36 @@ export class MaintenanceCompleteDialog extends LitElement {
? html`<div class="used-parts">
<span class="field-label">${t("complete_parts_used", L)}</span>
${this.parts.map((pt) => {
const qty = this._usedParts[pt.id];
const checked = qty !== undefined;
const key = partLinkKey({ part_id: pt.id, entry_id: pt.entry_id });
const link = this._usedParts[key];
const checked = link !== undefined;
const base: TaskPartLink = pt.entry_id
? { part_id: pt.id, quantity: 1, entry_id: pt.entry_id }
: { part_id: pt.id, quantity: 1 };
return html`<div class="used-part-row">
<label class="used-part-check">
<input type="checkbox" .checked=${checked}
@change=${(e: Event) => {
const next = { ...this._usedParts };
if ((e.target as HTMLInputElement).checked) next[pt.id] = next[pt.id] || 1;
else delete next[pt.id];
if ((e.target as HTMLInputElement).checked) next[key] = next[key] || base;
else delete next[key];
this._usedParts = next;
}} />
<span>${pt.name}${pt.stock !== null && pt.stock !== undefined ? ` (${pt.stock}${pt.unit ? " " + pt.unit : ""})` : ""}</span>
<span
>${pt.name}${pt.owner_name
? html`<span class="used-part-owner"> (${pt.owner_name})</span>`
: nothing}${pt.stock !== null && pt.stock !== undefined ? ` (${pt.stock}${pt.unit ? " " + pt.unit : ""})` : ""}</span
>
</label>
${checked
? html`<input class="used-part-qty" type="number" min="0.01" max="999" step="0.01"
.value=${String(qty)}
.value=${String(link.quantity)}
@input=${(e: Event) => {
const v = parseFloat((e.target as HTMLInputElement).value);
this._usedParts = { ...this._usedParts, [pt.id]: Number.isFinite(v) && v >= 0.01 ? v : 1 };
this._usedParts = {
...this._usedParts,
[key]: { ...base, quantity: Number.isFinite(v) && v >= 0.01 ? v : 1 },
};
}} />`
: nothing}
</div>`;
@@ -245,25 +297,25 @@ export class MaintenanceCompleteDialog extends LitElement {
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>
<span class="field-label">${t("notes_optional", L)}${this._req("notes")}</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>
<span class="field-label">${t("cost_optional", L)}${this._req("cost")}</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>
<span class="field-label">${t("duration_minutes", L)}${this._req("duration")}</span>
<input type="number" step="0.01" 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>
<span class="field-label">${t("completion_photo_optional", L)}${this._req("photo")}</span>
${this._photoPreview
? html`
<div class="photo-preview">
@@ -306,7 +358,10 @@ export class MaintenanceCompleteDialog extends LitElement {
</ha-button>
<ha-button
@click=${this._complete}
.disabled=${this._loading}
.disabled=${this._loading || this._missingRequired.length > 0}
title=${this._missingRequired.length
? this._missingRequired.map((f) => t("err_required", L).replace("{field}", t(REQUIRED_COMPLETION_LABELS[f] ?? f, L))).join(" · ")
: ""}
>
${this._loading ? t("completing", L) : t("complete", L)}
</ha-button>
@@ -316,6 +371,11 @@ export class MaintenanceCompleteDialog extends LitElement {
}
static styles = [nativeFieldStyles, css`
.req-mark {
color: var(--error-color, #f44336);
margin-left: 2px;
font-weight: 600;
}
.dialog-title {
font-size: 18px;
font-weight: 500;
@@ -348,6 +408,9 @@ export class MaintenanceCompleteDialog extends LitElement {
font-size: 13px; cursor: pointer;
}
.used-part-check input { cursor: pointer; }
/* #111: whose stock this row draws on. Muted but never omitted — an
unlabelled foreign pool is indistinguishable from an own part. */
.used-part-owner { color: var(--secondary-text-color); }
.used-part-qty {
width: 76px; padding: 4px 6px; border-radius: 4px; font: inherit; font-size: 13px;
border: 1px solid var(--divider-color);
@@ -155,9 +155,15 @@ export class MaintenanceQrDialog extends LitElement {
const completeLabel = escapeHtml(t("qr_action_complete", L));
w.document.write(`<!DOCTYPE html><html><head><meta charset="utf-8">
<meta name="color-scheme" content="light">
<title>${safeTitle}</title>
<style>
body{font-family:sans-serif;text-align:center;padding:20px}
/* Printable sheet — must not inherit the phone's dark theme. The QR images
carry their own white quiet zone and stay scannable either way, but the
labels below are explicit dark greys and would vanish on a WebView's dark
canvas. Same reasoning as helpers/report.ts. */
:root{color-scheme:light}
body{font-family:sans-serif;text-align:center;padding:20px;background:#fff;color:#1a1a1a}
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}
@@ -9,6 +9,15 @@ import { UserService } from "../user-service";
import { OBJECT_COLUMNS, sanitizeColumns } from "../helpers/object-columns";
import { downloadTextFile } from "../helpers/download";
/** One household member and the notify services they actually resolve to.
* Empty `services` means no Companion device is linked, so their reminders
* fall back to the household notification service. */
interface PersonNotifyTarget {
user_id: string;
name: string;
services: string[];
}
/* Settings response shape from WS maintenance_supporter/settings */
interface SettingsResponse {
features: AdvancedFeatures;
@@ -27,6 +36,8 @@ interface SettingsResponse {
notify_targets?: string[];
panel_enabled: boolean;
panel_title: string;
/** Opt-in copy of the shipped Assist sentences into the config dir. */
install_assist_sentences?: boolean;
};
notifications: {
due_soon_enabled: boolean;
@@ -108,6 +119,8 @@ export class MaintenanceSettingsView extends LitElement {
@state() private _includeHistory = true;
@state() private _toast = "";
@state() private _testingNotification = false;
@state() private _personTargets: PersonNotifyTarget[] = [];
@state() private _testingUser = "";
@state() private _users: HAUser[] = [];
@state() private _savedViews: Array<{ id: string; name: string }> = [];
@@ -169,6 +182,23 @@ export class MaintenanceSettingsView extends LitElement {
} catch {
this._users = [];
}
this._loadNotifyTargets();
}
/** Which notify services each household member resolves to.
*
* Resolved by the backend through the same helper the reminder path uses,
* so the list shown here is the list that will actually be used.
*/
private async _loadNotifyTargets(): Promise<void> {
try {
const res = await this.hass.connection.sendMessagePromise<{ targets: PersonNotifyTarget[] }>({
type: "maintenance_supporter/notify/user_targets",
});
this._personTargets = res.targets || [];
} catch {
this._personTargets = [];
}
}
private async _loadSettings(): Promise<void> {
@@ -220,11 +250,13 @@ export class MaintenanceSettingsView extends LitElement {
}
}
private _sendTestNotification = async (): Promise<void> => {
this._testingNotification = true;
private _sendTestNotification = async (userId?: string): Promise<void> => {
if (userId) this._testingUser = userId;
else this._testingNotification = true;
try {
const res = await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/global/test_notification",
...(userId ? { user_id: userId } : {}),
}) as { success: boolean; message?: string };
const msg = res.message
|| (res.success ? t("test_notification_success", this._lang) : t("test_notification_failed", this._lang));
@@ -232,7 +264,8 @@ export class MaintenanceSettingsView extends LitElement {
} catch {
this._showToast(t("test_notification_failed", this._lang));
} finally {
this._testingNotification = false;
if (userId) this._testingUser = "";
else this._testingNotification = false;
}
};
@@ -570,6 +603,12 @@ export class MaintenanceSettingsView extends LitElement {
@change=${(e: Event) => this._updateSetting("panel_title", (e.target as HTMLInputElement).value.trim())} />
</label>
` : ""}
<label class="setting-row">
<span class="setting-label">${t("settings_install_assist_sentences", L)}</span>
<input type="checkbox" .checked=${g.install_assist_sentences ?? false}
@change=${(e: Event) => this._updateSetting("install_assist_sentences", (e.target as HTMLInputElement).checked)} />
</label>
<div class="setting-hint">${t("settings_install_assist_sentences_hint", L)}</div>
<label class="setting-row">
<span class="setting-label">${t("settings_notifications", L)}</span>
<input type="checkbox" .checked=${g.notifications_enabled}
@@ -588,10 +627,28 @@ export class MaintenanceSettingsView extends LitElement {
<span class="setting-label">${t("test_notification", L)}</span>
<button class="ha-button secondary"
?disabled=${!g.notify_service || this._testingNotification}
@click=${this._sendTestNotification}>
@click=${() => this._sendTestNotification()}>
${this._testingNotification ? t("testing", L) : t("send_test", L)}
</button>
</div>
${this._personTargets.length ? html`
<div class="notify-per-person">
<span class="setting-label">${t("notify_per_person", L)}</span>
${this._personTargets.map((target) => html`
<div class="notify-person-row">
<span class="notify-person-name">${target.name}</span>
<span class="notify-person-target ${target.services.length ? "" : "muted"}">
${target.services.length ? target.services.join(", ") : t("notify_no_own_device", L)}
</span>
<button class="ha-button secondary"
?disabled=${!target.services.length || this._testingUser === target.user_id}
@click=${() => this._sendTestNotification(target.user_id)}>
${this._testingUser === target.user_id ? t("testing", L) : t("send_test", L)}
</button>
</div>
`)}
</div>
` : nothing}
` : nothing}
</div>
`;
@@ -1606,6 +1663,31 @@ export class MaintenanceSettingsView extends LitElement {
cursor: pointer;
gap: 12px;
}
.notify-per-person {
padding: 10px 0;
border-bottom: 1px solid var(--divider-color, #e0e0e0);
}
.notify-person-row {
display: flex;
align-items: center;
gap: 12px;
padding: 6px 0 0;
flex-wrap: wrap;
}
.notify-person-name {
font-weight: 500;
min-width: 120px;
}
.notify-person-target {
flex: 1;
min-width: 160px;
font-size: 0.9em;
word-break: break-word;
color: var(--secondary-text-color, #727272);
}
.notify-person-target.muted {
font-style: italic;
}
/* v2.27: template gallery clustered by category */
.tpl-group { margin-top: 14px; }
.tpl-group-head {
@@ -2,11 +2,13 @@
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import type { AdaptiveConfig, HomeAssistant, MaintenanceTask, TriggerConfig, HAUser } from "../types";
import type { AdaptiveConfig, HomeAssistant, MaintenanceTask, TaskPartLink, TriggerConfig, HAUser } from "../types";
import { formatDate, t, weekdayName } from "../styles";
import { UserService } from "../user-service";
import { partLinkKey } from "../helpers/shared-parts";
import { describeWsError } from "../ws-errors";
import { REQUIRED_COMPLETION_KEYS, REQUIRED_COMPLETION_LABELS } from "./required-completion-labels";
import "./ms-textfield";
const MAINTENANCE_TYPE_KEYS = ["cleaning", "inspection", "replacement", "calibration", "service", "reading", "custom"];
@@ -122,6 +124,14 @@ export class MaintenanceTaskDialog extends LitElement {
@property({ type: Number, attribute: "default-warning-days" }) public defaultWarningDays = 7;
/** The object's spare parts — offered as "consumes parts" checkboxes. */
@state() private parts: Array<{ id: string; name: string; unit?: string }> = [];
/** #111: OTHER objects that own spare parts, so a task can draw on a shared
* pool (three vacuums, one box of dust bags). Grouped by owner in the UI —
* which pool a link means must never be a guess. */
@state() private _foreignOwners: Array<{
entry_id: string;
name: string;
parts: Array<{ id: string; name: string; unit?: string }>;
}> = [];
@state() private _open = false;
@state() private _loading = false;
@state() private _error = "";
@@ -210,7 +220,9 @@ export class MaintenanceTaskDialog extends LitElement {
@state() private _nfcTagId = "";
// v2.20 (#83): unit for `reading`-type tasks ("kWh", "m³", ...)
@state() private _readingUnit = "";
@state() private _consumesParts: Record<string, number> = {};
/** The picked links, keyed by `partLinkKey` — the (entry_id, part_id) pair,
* since the same part id can exist on two objects (battery fleet). */
@state() private _consumesParts: Record<string, TaskPartLink> = {};
@state() private _partsLoadFailed = false;
@state() private _availableTags: Array<{id: string; name: string}> = [];
@@ -222,6 +234,9 @@ export class MaintenanceTaskDialog extends LitElement {
// Checklist (newline-separated steps, one per line)
@state() private _checklistText = "";
/** Details this task demands on completion — enforced by the backend on
* every surface, so a button press or voice command cannot bypass it. */
@state() private _requiredCompletion: string[] = [];
// Schedule time (HH:MM, advanced feature)
@state() private _scheduleTime = "";
@@ -271,7 +286,7 @@ export class MaintenanceTaskDialog extends LitElement {
this._objectChoices = [];
}
this._resetFields();
await Promise.all([this._loadUsers(), this._loadTags(), this._loadParts()]);
await Promise.all([this._loadUsers(), this._loadTags(), this._loadParts(), this._loadForeignPools()]);
this._open = true;
}
@@ -321,12 +336,17 @@ export class MaintenanceTaskDialog extends LitElement {
this._lastPerformed = task.last_performed || "";
this._nfcTagId = task.nfc_tag_id || "";
this._readingUnit = task.reading_unit || "";
this._consumesParts = Object.fromEntries((task.consumes_parts || []).map((l) => [l.part_id, l.quantity]));
// Whole link, entry_id included — hydrating only part_id would turn every
// shared-pool link into an own-part link on the next save (#111).
this._consumesParts = Object.fromEntries(
(task.consumes_parts || []).map((l) => [partLinkKey(l), { ...l }]),
);
this._responsibleUserId = task.responsible_user_id || null;
this._assigneePool = [...(task.assignee_pool || [])];
this._rotationStrategy = task.rotation_strategy || "";
this._checklistText = (task.checklist || []).join("\n");
this._requiredCompletion = [...(task.required_completion_fields || [])];
this._scheduleTime = task.schedule_time || "";
// v1.3.0: hydrate on_complete_action + quick_complete_defaults
@@ -395,7 +415,7 @@ export class MaintenanceTaskDialog extends LitElement {
this._fetchEntityAttributes(this._triggerEntityId);
}
await Promise.all([this._loadUsers(), this._loadTags(), this._loadParts()]);
await Promise.all([this._loadUsers(), this._loadTags(), this._loadParts(), this._loadForeignPools()]);
this._open = true;
}
@@ -434,6 +454,7 @@ export class MaintenanceTaskDialog extends LitElement {
this._assigneePool = [];
this._rotationStrategy = "";
this._checklistText = "";
this._requiredCompletion = [];
this._scheduleTime = "";
this._environmentalEntity = "";
this._environmentalAttribute = "";
@@ -736,6 +757,40 @@ export class MaintenanceTaskDialog extends LitElement {
}
}
/** #111: the other objects' spare-part pools this task could draw on.
*
* A sibling of `_loadParts`, run in the SAME Promise.all rather than nested
* inside it: chaining it after the own-parts fetch delays the dialog opening
* by a further round trip for a list that is secondary to it.
*
* Failure is soft on purpose — the own-parts picker is the primary path and
* must not disappear because this second call did not come back. */
private async _loadForeignPools(): Promise<void> {
this._foreignOwners = [];
if (!this._entryId) return;
try {
const result = (await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/objects",
})) as {
objects?: Array<{
entry_id: string;
object?: { name?: string };
parts?: Array<{ id: string; name: string; unit?: string }>;
}>;
};
this._foreignOwners = (result.objects || [])
.filter((o) => o.entry_id !== this._entryId && (o.parts || []).length > 0)
.map((o) => ({
entry_id: o.entry_id,
name: o.object?.name || o.entry_id,
parts: o.parts || [],
}))
.sort((a, b) => a.name.localeCompare(b.name));
} catch {
this._foreignOwners = [];
}
}
private async _loadTags(): Promise<void> {
try {
const result = await this.hass.connection.sendMessagePromise({
@@ -773,6 +828,69 @@ export class MaintenanceTaskDialog extends LitElement {
}
}
/** A task already drawing on a shared pool opens that section expanded — a
* collapsed disclosure would hide a link that is very much active. */
private get _hasForeignPick(): boolean {
return Object.values(this._consumesParts).some((l) => !!l.entry_id);
}
/** One "consumes parts" checkbox + quantity.
*
* `ownerEntryId` is undefined for the object's own parts and set for a pool
* owned by another object (#111) — that argument is the ONLY difference
* between the two lists, which is why they share this renderer. */
private _renderConsumesRow(
part: { id: string; name: string; unit?: string },
ownerEntryId?: string,
) {
const key = partLinkKey({ part_id: part.id, entry_id: ownerEntryId });
const link = this._consumesParts[key];
const base: TaskPartLink = ownerEntryId
? { part_id: part.id, quantity: 1, entry_id: ownerEntryId }
: { part_id: part.id, quantity: 1 };
return html`
<div class="consumes-row">
<label class="consumes-check">
<input
type="checkbox"
.checked=${link !== undefined}
@change=${(e: Event) => {
const next = { ...this._consumesParts };
if ((e.target as HTMLInputElement).checked) next[key] = next[key] || base;
else delete next[key];
this._consumesParts = next;
}}
/>
<span>${part.name}${part.unit ? ` (${part.unit})` : ""}</span>
</label>
${link !== undefined
? html`<input
class="consumes-qty"
type="number"
min="0.01"
max="999"
step="0.01"
.value=${String(link.quantity)}
@input=${(e: Event) => {
const v = parseFloat((e.target as HTMLInputElement).value);
this._consumesParts = {
...this._consumesParts,
[key]: { ...base, quantity: Number.isFinite(v) && v >= 0.01 ? v : 1 },
};
}}
/>`
: nothing}
</div>
`;
}
private _toggleRequired(field: string, on: boolean): void {
const next = new Set(this._requiredCompletion);
if (on) next.add(field);
else next.delete(field);
this._requiredCompletion = [...next];
}
private async _save(): Promise<void> {
if (this._loading) return; // synchronous re-entry guard (double-click)
if (!this._name.trim()) return;
@@ -787,7 +905,16 @@ export class MaintenanceTaskDialog extends LitElement {
name: this._name,
task_type: this._type,
schedule_type: this._scheduleType,
warning_days: parseInt(this._warningDays, 10) || 7,
// `0` is a legal, meaningful value — "no due-soon window, go straight
// from ok to overdue" (backend range is 0365). The old
// `parseInt(...) || 7` treated it as falsy and silently rewrote a
// stored 0 to 7 on EVERY save, even when the user never touched the
// field. Same class as bug #42, but worse: it needed no user action.
// Only a genuinely unparseable field falls back, and to the
// configured default rather than a hardcoded 7.
warning_days: Number.isNaN(parseInt(this._warningDays, 10))
? this.defaultWarningDays
: Math.max(0, parseInt(this._warningDays, 10)),
};
const ecd = this._earliestCompletionDays.trim();
data.earliest_completion_days = ecd === "" ? null : Math.max(0, parseInt(ecd, 10) || 0);
@@ -836,14 +963,20 @@ export class MaintenanceTaskDialog extends LitElement {
data.last_performed = this._lastPerformed || null;
data.nfc_tag_id = this._nfcTagId || null;
data.reading_unit = this._readingUnit.trim() || null;
if (this.parts.length) {
data.consumes_parts = Object.entries(this._consumesParts).map(([part_id, quantity]) => ({
part_id,
quantity,
}));
// Only send when a picker was actually rendered. A failed parts load
// leaves both lists empty, and sending [] then would silently wipe links
// the user never saw. entry_id is written ONLY for a foreign pick, so an
// own-parts task saves byte-identically to before (#111).
if (this.parts.length || this._foreignOwners.length) {
data.consumes_parts = Object.values(this._consumesParts).map((l) =>
l.entry_id
? { part_id: l.part_id, quantity: l.quantity, entry_id: l.entry_id }
: { part_id: l.part_id, quantity: l.quantity },
);
}
data.responsible_user_id = this._responsibleUserId;
data.assignee_pool = this._assigneePool;
data.required_completion_fields = this._requiredCompletion;
data.rotation_strategy =
this._assigneePool.length >= 2 && this._rotationStrategy
? this._rotationStrategy
@@ -1700,6 +1833,9 @@ export class MaintenanceTaskDialog extends LitElement {
this._entryId = (e.target as HTMLSelectElement).value;
this._consumesParts = {};
this._loadParts();
// The new owner drops out of the shared-pool list and the old
// one joins it, so this has to be recomputed too (#111).
this._loadForeignPools();
}}
>
${this._objectChoices.map(
@@ -1738,44 +1874,27 @@ export class MaintenanceTaskDialog extends LitElement {
${this._partsLoadFailed
? html`<div class="field-help parts-load-failed">${t("parts_load_failed", L)}</div>`
: nothing}
${this.parts.length
${this.parts.length || this._foreignOwners.length
? html`
<div class="field">
<label>${t("consumes_parts_label", L)}</label>
${this.parts.map((part) => {
const qty = this._consumesParts[part.id];
return html`
<div class="consumes-row">
<label class="consumes-check">
<input
type="checkbox"
.checked=${qty !== undefined}
@change=${(e: Event) => {
const next = { ...this._consumesParts };
if ((e.target as HTMLInputElement).checked) next[part.id] = next[part.id] || 1;
else delete next[part.id];
this._consumesParts = next;
}}
/>
<span>${part.name}${part.unit ? ` (${part.unit})` : ""}</span>
</label>
${qty !== undefined
? html`<input
class="consumes-qty"
type="number"
min="0.01"
max="999"
step="0.01"
.value=${String(qty)}
@input=${(e: Event) => {
const v = parseFloat((e.target as HTMLInputElement).value);
this._consumesParts = { ...this._consumesParts, [part.id]: Number.isFinite(v) && v >= 0.01 ? v : 1 };
}}
/>`
: nothing}
</div>
`;
})}
${this.parts.map((part) => this._renderConsumesRow(part))}
${this._foreignOwners.length
? html`
<details class="shared-pools" ?open=${this._hasForeignPick}>
<summary>${t("shared_parts_other_objects", L)}</summary>
<div class="field-help">${t("shared_parts_help", L)}</div>
${this._foreignOwners.map(
(owner) => html`
<div class="shared-pool-owner">${owner.name}</div>
${owner.parts.map((part) =>
this._renderConsumesRow(part, owner.entry_id),
)}
`,
)}
</details>
`
: nothing}
</div>
`
: nothing}
@@ -1857,6 +1976,8 @@ export class MaintenanceTaskDialog extends LitElement {
<ms-textfield
label="${t("warning_days", L)}"
type="number"
min="0"
max="365"
.value=${this._warningDays}
@input=${(e: Event) => (this._warningDays = (e.target as HTMLInputElement).value)}
></ms-textfield>
@@ -1879,6 +2000,19 @@ export class MaintenanceTaskDialog extends LitElement {
></textarea>
<div class="field-help">${t("checklist_help", L)}</div>
` : nothing}
<h3>${t("require_on_completion", L)}</h3>
<div class="required-completion">
${REQUIRED_COMPLETION_KEYS.map((field) => html`
<label class="req-option">
<input
type="checkbox"
.checked=${this._requiredCompletion.includes(field)}
@change=${(e: Event) => this._toggleRequired(field, (e.target as HTMLInputElement).checked)}
/>
<span>${t(REQUIRED_COMPLETION_LABELS[field], L)}</span>
</label>
`)}
</div>
<ms-textfield
label="${t("last_performed_optional", L)}"
type="date"
@@ -2127,6 +2261,24 @@ export class MaintenanceTaskDialog extends LitElement {
background: var(--card-background-color);
color: var(--primary-text-color);
}
/* #111: other objects' pools sit behind a disclosure so the object's OWN
parts stay the primary list; each group is headed by the owning object's
name, so which pool a checkbox means is never a guess. */
.shared-pools {
margin-top: 6px;
}
.shared-pools > summary {
cursor: pointer;
padding: 2px 0;
font-size: 13px;
color: var(--secondary-text-color);
}
.shared-pool-owner {
margin-top: 6px;
font-size: 12px;
font-weight: 500;
color: var(--secondary-text-color);
}
.field-help {
font-size: 12px;
color: var(--secondary-text-color);
@@ -14,7 +14,7 @@
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 { sharedStyles, t, STATUS_COLORS, formatDate, formatDateTime, formatInterval, formatRecurrence } from "../styles";
import { describeWsError } from "../ws-errors";
import { renderWeibullSection } from "../renderers/weibull";
import { renderPredictionSection } from "../renderers/prediction";
@@ -294,7 +294,9 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
task_id: this._taskId,
});
this._toast = r.recommended_interval
? `${t("reanalyze_result", this._lang) || "Recomputed"}: ${r.recommended_interval}d (${r.data_points} pts)`
// The analyzer always works in DAYS (helpers/interval_analyzer.py), so
// the unit is pinned here rather than taken from the task's own unit.
? `${t("reanalyze_result", this._lang) || "Recomputed"}: ${formatInterval(r.recommended_interval, "days", this._lang)} (${r.data_points} pts)`
: (t("reanalyze_insufficient_data", this._lang) || "Not enough data");
await this._loadTask();
setTimeout(() => { this._toast = ""; }, 3500);
@@ -252,6 +252,8 @@ export function openCompleteDialog(args: {
task_name: string;
checklist?: string[];
adaptive_enabled?: boolean;
/** Details the task demands before it counts as done (v2.44). */
required_completion_fields?: string[];
}): boolean {
const dlg = getOrCreate<MaintenanceCompleteDialog>(COMPLETE_DIALOG_TAG);
if (!syncHass(dlg)) return false;
@@ -260,6 +262,7 @@ export function openCompleteDialog(args: {
dlg.taskName = args.task_name;
dlg.checklist = args.checklist ?? [];
dlg.adaptiveEnabled = !!args.adaptive_enabled;
dlg.requiredFields = args.required_completion_fields ?? [];
dlg.lang = (getHass()?.language) || "en";
dlg.open();
return true;
@@ -23,6 +23,14 @@ const common = {
sourcemap: false,
external: [],
define: { __MS_BUNDLE_VERSION__: JSON.stringify(manifestVersion) },
// A readable banner as well as the define. After the minifier is done the
// stamped version survives only as `var xy="2.44.1"` with a generated name,
// which nothing outside the bundle can reliably find — so 2.44.0 shipped a
// bundle built before the version bump and every install showed a permanent
// "reload the panel" banner (#112). This line is what
// tests/test_frontend_bundle_version.py checks, and it also lets anyone read
// the built version straight out of devtools.
banner: { js: `/*! maintenance_supporter frontend ${manifestVersion} */` },
};
// Panel
@@ -73,10 +73,19 @@ export function buildObjectReportHtml(
const totalCost = tasks.reduce((n, t) => n + (t.total_cost ?? 0), 0);
return `<!DOCTYPE html><html><head><meta charset="utf-8">
<meta name="color-scheme" content="light">
<title>${esc(labels.title)}${esc(obj.name)}</title>
<style>
/* This is a PRINTABLE sheet, not part of the app's theme: it opens as a
blob in whatever viewer the OS supplies. In the Companion app that is a
WebView, and a WebView on a dark-themed phone paints a DARK default
canvas — against which the dark body text below disappeared completely,
leaving only the pale row borders showing as stripes. Declaring the
scheme AND painting the background keeps the sheet identical everywhere,
and matches what comes out of a printer. */
:root { color-scheme: light; }
* { box-sizing: border-box; }
body { font: 13px/1.5 -apple-system, Segoe UI, Roboto, sans-serif; color: #1a1a1a; margin: 32px; }
body { font: 13px/1.5 -apple-system, Segoe UI, Roboto, sans-serif; color: #1a1a1a; background: #fff; 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; }
@@ -83,11 +83,17 @@ export function buildTaskWorksheetHtml(
: "";
return `<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>${esc(task.name)}${esc(L.title)}</title>
<html><head><meta charset="utf-8"><meta name="color-scheme" content="light">
<title>${esc(task.name)}${esc(L.title)}</title>
<style>
/* A work sheet is meant to be printed or read as a sheet, so it must not
inherit the phone's dark theme: the Companion app opens it in a WebView
that paints a dark canvas, and this dark text would vanish against it.
See the same note in report.ts. */
:root { color-scheme: light; }
@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; }
body { font: 13px/1.45 -apple-system, "Segoe UI", Roboto, sans-serif; color: #111; background: #fff; 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; }
@@ -108,6 +108,7 @@
"trigger_val": "Hodnota spouštěče",
"complete_title": "Dokončit: ",
"checklist": "Kontrolní seznam",
"require_on_completion": "Vyžadovat při dokončení",
"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.",
@@ -362,6 +363,8 @@
"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_areas": "Filtrovat podle oblastí",
"card_filter_areas_help": "Prázdné = zobrazit všechny oblasti.",
"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ů…",
@@ -431,11 +434,15 @@
"settings_panel_title": "Název panelu",
"settings_notifications": "Oznámení",
"settings_notify_service": "Služba oznámení",
"settings_install_assist_sentences": "Nainstalovat věty pro Assist",
"settings_install_assist_sentences_hint": "Zkopíruje hlasové věty do vaší konfigurace, aby je klasický agent Assist rozpoznal. Soubor, který jste upravili, nebude nikdy přepsán.",
"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",
"notify_per_person": "Doručování podle osoby",
"notify_no_own_device": "Žádné vlastní zařízení — použije domácí službu",
"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",
@@ -487,6 +494,7 @@
"groupby_user": "Podle uživatele",
"filter_label": "Filtr",
"user_label": "Uživatel",
"photo_label": "Fotografie",
"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.",
@@ -722,6 +730,9 @@
"part_restock": "Upravit zásobu",
"restock_quantity_label": "Zakoupené množství",
"consumes_parts_label": "Spotřebovává díly",
"shared_parts_other_objects": "Díly z jiných objektů",
"shared_parts_help": "Několik objektů může sdílet jeden sklad. Dokončení tohoto úkolu odečte zásobu z vlastnícího objektu.",
"shared_part_unknown": "Neznámý díl",
"parts_load_failed": "Nepodařilo se načíst díly tohoto objektu — možnosti spotřeby dílů nyní nejsou k dispozici.",
"settings_export_selection": "Omezit na vybrané objekty (volitelné)",
"settings_docs_archive": "Archiv dokumentů (se soubory)",
@@ -109,6 +109,7 @@
"trigger_val": "Udløserværdi",
"complete_title": "Fuldfør: ",
"checklist": "Tjekliste",
"require_on_completion": "Kræv ved fuldførelse",
"checklist_steps_optional": "Tjeklistetrin (valgfrit)",
"checklist_placeholder": "Rengør filter\nUdskift pakning\nTest tryk",
"checklist_help": "Ét trin pr. linje. Maks. 100 elementer.",
@@ -363,6 +364,8 @@
"card_filter_status_help": "Tom = vis alle statusser.",
"card_filter_objects": "Filtrer efter objekter",
"card_filter_objects_help": "Tom = vis alle objekter.",
"card_filter_areas": "Filtrer efter områder",
"card_filter_areas_help": "Tom = vis alle områder.",
"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…",
@@ -432,11 +435,15 @@
"settings_panel_title": "Sidepanelets titel",
"settings_notifications": "Notifikationer",
"settings_notify_service": "Notifikationstjeneste",
"settings_install_assist_sentences": "Installer Assist-sætninger",
"settings_install_assist_sentences_hint": "Kopierer stemmesætningerne til din konfiguration, så den klassiske Assist-agent genkender dem. En fil, du selv har redigeret, overskrives aldrig.",
"test_notification": "Testnotifikation",
"send_test": "Send test",
"testing": "Sender…",
"test_notification_success": "Testnotifikation sendt",
"test_notification_failed": "Testnotifikation mislykkedes",
"notify_per_person": "Levering pr. person",
"notify_no_own_device": "Ingen egen enhed — bruger husstandens tjeneste",
"settings_notify_due_soon": "Notificer ved snart forfalden",
"settings_notify_overdue": "Notificer ved forfalden",
"settings_notify_triggered": "Notificer ved udløst",
@@ -488,6 +495,7 @@
"groupby_user": "Efter bruger",
"filter_label": "Filter",
"user_label": "Bruger",
"photo_label": "Foto",
"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.",
@@ -722,6 +730,9 @@
"part_restock": "Justér lager",
"restock_quantity_label": "Købt mængde",
"consumes_parts_label": "Forbruger dele",
"shared_parts_other_objects": "Dele fra andre objekter",
"shared_parts_help": "Flere objekter kan dele det samme lager. Når opgaven fuldføres, trækkes der fra det ejende objekt.",
"shared_part_unknown": "Ukendt del",
"parts_load_failed": "Kunne ikke indlæse objektets reservedele — forbrugsindstillingerne er ikke tilgængelige lige nu.",
"settings_export_selection": "Begræns til valgte objekter (valgfrit)",
"settings_docs_archive": "Dokumentarkiv (med filer)",
@@ -109,6 +109,7 @@
"trigger_val": "Trigger-Wert",
"complete_title": "Erledigt: ",
"checklist": "Checkliste",
"require_on_completion": "Beim Abschließen verlangen",
"checklist_steps_optional": "Checkliste-Schritte (optional)",
"checklist_placeholder": "Filter reinigen\nDichtung ersetzen\nDruck testen",
"checklist_help": "Ein Schritt pro Zeile. Max. 100 Einträge.",
@@ -363,6 +364,8 @@
"card_filter_status_help": "Leer = alle Status zeigen.",
"card_filter_objects": "Nach Objekten filtern",
"card_filter_objects_help": "Leer = alle Objekte zeigen.",
"card_filter_areas": "Nach Bereichen filtern",
"card_filter_areas_help": "Leer = alle Bereiche 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…",
@@ -432,11 +435,15 @@
"settings_panel_title": "Panel-Titel",
"settings_notifications": "Benachrichtigungen",
"settings_notify_service": "Benachrichtigungsdienst",
"settings_install_assist_sentences": "Assist-Sätze installieren",
"settings_install_assist_sentences_hint": "Kopiert die Sprachbefehle in deine Konfiguration, damit der klassische Assist-Agent sie erkennt. Eine selbst bearbeitete Datei wird nie überschrieben.",
"test_notification": "Test-Benachrichtigung",
"send_test": "Test senden",
"testing": "Sende…",
"test_notification_success": "Test-Benachrichtigung gesendet",
"test_notification_failed": "Test-Benachrichtigung fehlgeschlagen",
"notify_per_person": "Zustellung pro Person",
"notify_no_own_device": "Kein eigenes Gerät — nutzt den Haushaltsdienst",
"settings_notify_due_soon": "Bei baldiger Fälligkeit benachrichtigen",
"settings_notify_overdue": "Bei Überfälligkeit benachrichtigen",
"settings_notify_triggered": "Bei Auslösung benachrichtigen",
@@ -488,6 +495,7 @@
"groupby_user": "Nach Verantwortlichem",
"filter_label": "Filter",
"user_label": "Benutzer",
"photo_label": "Foto",
"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.",
@@ -722,6 +730,9 @@
"part_restock": "Bestand anpassen",
"restock_quantity_label": "Gekaufte Menge",
"consumes_parts_label": "Verbraucht Teile",
"shared_parts_other_objects": "Teile anderer Objekte",
"shared_parts_help": "Mehrere Objekte können sich einen Bestand teilen. Beim Abschließen wird vom besitzenden Objekt abgebucht.",
"shared_part_unknown": "Unbekanntes Teil",
"parts_load_failed": "Die Teile dieses Objekts konnten nicht geladen werden — die Teileverbrauch-Optionen sind gerade nicht verfügbar.",
"settings_export_selection": "Auf ausgewählte Objekte beschränken (optional)",
"settings_docs_archive": "Dokumentenarchiv (mit Dateien)",
@@ -109,6 +109,7 @@
"trigger_val": "Trigger value",
"complete_title": "Complete: ",
"checklist": "Checklist",
"require_on_completion": "Require on completion",
"checklist_steps_optional": "Checklist steps (optional)",
"checklist_placeholder": "Clean filter\nReplace seal\nTest pressure",
"checklist_help": "One step per line. Max 100 items.",
@@ -363,6 +364,8 @@
"card_filter_status_help": "Empty = show all statuses.",
"card_filter_objects": "Filter by objects",
"card_filter_objects_help": "Empty = show all objects.",
"card_filter_areas": "Filter by areas",
"card_filter_areas_help": "Empty = show all areas.",
"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…",
@@ -432,11 +435,15 @@
"settings_panel_title": "Sidebar panel title",
"settings_notifications": "Notifications",
"settings_notify_service": "Notification service",
"settings_install_assist_sentences": "Install Assist sentences",
"settings_install_assist_sentences_hint": "Copies the voice sentences into your configuration so the classic Assist agent recognises them. A file you edited yourself is never overwritten.",
"test_notification": "Test notification",
"send_test": "Send test",
"testing": "Sending…",
"test_notification_success": "Test notification sent",
"test_notification_failed": "Test notification failed",
"notify_per_person": "Per-person delivery",
"notify_no_own_device": "No own device — uses the household service",
"settings_notify_due_soon": "Notify when due soon",
"settings_notify_overdue": "Notify when overdue",
"settings_notify_triggered": "Notify when triggered",
@@ -494,6 +501,7 @@
"groupby_user": "By user",
"filter_label": "Filter",
"user_label": "User",
"photo_label": "Photo",
"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.",
@@ -728,6 +736,9 @@
"part_restock": "Adjust stock",
"restock_quantity_label": "Quantity bought",
"consumes_parts_label": "Consumes parts",
"shared_parts_other_objects": "Parts from other objects",
"shared_parts_help": "Several objects can share one stock. Completing this task takes from the owning object.",
"shared_part_unknown": "Unknown part",
"parts_load_failed": "Couldn't load this object's parts — the consumes-parts options are unavailable right now.",
"adopt_problem_button": "Adopt problem sensors",
"adopt_problem_title": "Adopt problem sensors",
@@ -108,6 +108,7 @@
"trigger_val": "Valor del disparador",
"complete_title": "Completada: ",
"checklist": "Lista de verificación",
"require_on_completion": "Exigir al completar",
"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.",
@@ -362,6 +363,8 @@
"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_areas": "Filtrar por áreas",
"card_filter_areas_help": "Vacío = mostrar todas las áreas.",
"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…",
@@ -431,11 +434,15 @@
"settings_panel_title": "Título del panel lateral",
"settings_notifications": "Notificaciones",
"settings_notify_service": "Servicio de notificación",
"settings_install_assist_sentences": "Instalar frases de Assist",
"settings_install_assist_sentences_hint": "Copia las frases de voz en tu configuración para que el agente clásico de Assist las reconozca. Un archivo que hayas editado nunca se sobrescribe.",
"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ó",
"notify_per_person": "Entrega por persona",
"notify_no_own_device": "Sin dispositivo propio — usa el servicio del hogar",
"settings_notify_due_soon": "Notificar cuando esté próxima",
"settings_notify_overdue": "Notificar cuando esté vencida",
"settings_notify_triggered": "Notificar cuando se active",
@@ -487,6 +494,7 @@
"groupby_user": "Por usuario",
"filter_label": "Filtro",
"user_label": "Usuario",
"photo_label": "Foto",
"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.",
@@ -722,6 +730,9 @@
"part_restock": "Ajustar existencias",
"restock_quantity_label": "Cantidad comprada",
"consumes_parts_label": "Consume piezas",
"shared_parts_other_objects": "Piezas de otros objetos",
"shared_parts_help": "Varios objetos pueden compartir un mismo stock. Al completar esta tarea se descuenta del objeto propietario.",
"shared_part_unknown": "Pieza desconocida",
"parts_load_failed": "No se pudieron cargar las piezas de este objeto — las opciones de consumo de piezas no están disponibles ahora.",
"settings_export_selection": "Limitar a los objetos seleccionados (opcional)",
"settings_docs_archive": "Archivo de documentos (con archivos)",
@@ -109,6 +109,7 @@
"trigger_val": "Laukaisuarvo",
"complete_title": "Suorita: ",
"checklist": "Tarkistuslista",
"require_on_completion": "Vaadi valmistuessa",
"checklist_steps_optional": "Tarkistuslistan vaiheet (valinnainen)",
"checklist_placeholder": "Puhdista suodatin\nVaihda tiiviste\nTarkista paine",
"checklist_help": "Yksi vaihe riviä kohden. Enintään 100 kohdetta.",
@@ -363,6 +364,8 @@
"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_areas": "Suodata alueiden mukaan",
"card_filter_areas_help": "Tyhjä = näytä kaikki alueet.",
"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…",
@@ -432,11 +435,15 @@
"settings_panel_title": "Sivupalkin paneelin otsikko",
"settings_notifications": "Ilmoitukset",
"settings_notify_service": "Ilmoituspalvelu",
"settings_install_assist_sentences": "Asenna Assist-lauseet",
"settings_install_assist_sentences_hint": "Kopioi äänikomennot asetuksiisi, jotta perinteinen Assist-agentti tunnistaa ne. Itse muokkaamaasi tiedostoa ei koskaan korvata.",
"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",
"notify_per_person": "Toimitus henkilöittäin",
"notify_no_own_device": "Ei omaa laitetta — käyttää kotitalouden palvelua",
"settings_notify_due_soon": "Ilmoita, kun erääntyy pian",
"settings_notify_overdue": "Ilmoita, kun myöhässä",
"settings_notify_triggered": "Ilmoita, kun laukaistu",
@@ -488,6 +495,7 @@
"groupby_user": "Käyttäjän mukaan",
"filter_label": "Suodata",
"user_label": "Käyttäjä",
"photo_label": "Valokuva",
"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.",
@@ -722,6 +730,9 @@
"part_restock": "Muuta varastoa",
"restock_quantity_label": "Ostettu määrä",
"consumes_parts_label": "Kuluttaa osia",
"shared_parts_other_objects": "Muiden kohteiden osat",
"shared_parts_help": "Useat kohteet voivat jakaa saman varaston. Tämän tehtävän suorittaminen vähentää omistavan kohteen varastoa.",
"shared_part_unknown": "Tuntematon osa",
"parts_load_failed": "Kohteen osia ei voitu ladata — osien kulutusvalinnat eivät ole juuri nyt käytettävissä.",
"settings_export_selection": "Rajaa valittuihin kohteisiin (valinnainen)",
"settings_docs_archive": "Asiakirja-arkisto (tiedostoineen)",
@@ -108,6 +108,7 @@
"trigger_val": "Valeur du déclencheur",
"complete_title": "Terminé : ",
"checklist": "Checklist",
"require_on_completion": "Exiger à la clôture",
"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.",
@@ -362,6 +363,8 @@
"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_areas": "Filtrer par zones",
"card_filter_areas_help": "Vide = afficher toutes les zones.",
"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…",
@@ -431,11 +434,15 @@
"settings_panel_title": "Titre du panneau latéral",
"settings_notifications": "Notifications",
"settings_notify_service": "Service de notification",
"settings_install_assist_sentences": "Installer les phrases Assist",
"settings_install_assist_sentences_hint": "Copie les phrases vocales dans votre configuration afin que l'agent Assist classique les reconnaisse. Un fichier que vous avez modifié n'est jamais écrasé.",
"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",
"notify_per_person": "Distribution par personne",
"notify_no_own_device": "Aucun appareil propre — utilise le service du foyer",
"settings_notify_due_soon": "Notifier quand bientôt dû",
"settings_notify_overdue": "Notifier quand en retard",
"settings_notify_triggered": "Notifier quand déclenché",
@@ -487,6 +494,7 @@
"groupby_user": "Par utilisateur",
"filter_label": "Filtre",
"user_label": "Utilisateur",
"photo_label": "Photo",
"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.",
@@ -722,6 +730,9 @@
"part_restock": "Ajuster le stock",
"restock_quantity_label": "Quantité achetée",
"consumes_parts_label": "Consomme des pièces",
"shared_parts_other_objects": "Pièces d'autres objets",
"shared_parts_help": "Plusieurs objets peuvent partager un même stock. Terminer cette tâche décompte du stock de l'objet propriétaire.",
"shared_part_unknown": "Pièce inconnue",
"parts_load_failed": "Impossible de charger les pièces de cet objet — les options de consommation de pièces sont indisponibles pour le moment.",
"settings_export_selection": "Limiter aux objets sélectionnés (facultatif)",
"settings_docs_archive": "Archive de documents (avec fichiers)",
@@ -109,6 +109,7 @@
"trigger_val": "ट्रिगर मान",
"complete_title": "पूर्ण करें: ",
"checklist": "चेकलिस्ट",
"require_on_completion": "पूर्ण करते समय आवश्यक",
"checklist_steps_optional": "चेकलिस्ट चरण (वैकल्पिक)",
"checklist_placeholder": "फ़िल्टर साफ़ करें\nसील बदलें\nदबाव जाँचें",
"checklist_help": "प्रति पंक्ति एक चरण। अधिकतम 100 आइटम।",
@@ -363,6 +364,8 @@
"card_filter_status_help": "खाली = सभी स्थितियाँ दिखाएँ।",
"card_filter_objects": "वस्तुओं के अनुसार फ़िल्टर करें",
"card_filter_objects_help": "खाली = सभी वस्तुएँ दिखाएँ।",
"card_filter_areas": "क्षेत्रों के अनुसार फ़िल्टर करें",
"card_filter_areas_help": "खाली = सभी क्षेत्र दिखाएँ।",
"card_filter_entities": "एंटिटी के अनुसार फ़िल्टर करें (entity_ids)",
"card_filter_entities_help": "इस इंटीग्रेशन से sensor / binary_sensor एंटिटी चुनें। खाली = सभी।",
"card_loading_objects": "वस्तुएँ लोड हो रही हैं…",
@@ -432,11 +435,15 @@
"settings_panel_title": "साइडबार पैनल शीर्षक",
"settings_notifications": "सूचनाएँ",
"settings_notify_service": "सूचना सेवा",
"settings_install_assist_sentences": "Assist वाक्य इंस्टॉल करें",
"settings_install_assist_sentences_hint": "वॉइस वाक्यों को आपके कॉन्फ़िगरेशन में कॉपी करता है ताकि क्लासिक Assist एजेंट उन्हें पहचान सके। आपके द्वारा संपादित फ़ाइल कभी अधिलेखित नहीं होती।",
"test_notification": "परीक्षण सूचना",
"send_test": "परीक्षण भेजें",
"testing": "भेजा जा रहा है…",
"test_notification_success": "परीक्षण सूचना भेजी गई",
"test_notification_failed": "परीक्षण सूचना विफल",
"notify_per_person": "प्रति व्यक्ति वितरण",
"notify_no_own_device": "कोई निजी डिवाइस नहीं — घरेलू सेवा का उपयोग करता है",
"settings_notify_due_soon": "जल्द देय होने पर सूचित करें",
"settings_notify_overdue": "अतिदेय होने पर सूचित करें",
"settings_notify_triggered": "ट्रिगर होने पर सूचित करें",
@@ -488,6 +495,7 @@
"groupby_user": "उपयोगकर्ता के अनुसार",
"filter_label": "फ़िल्टर",
"user_label": "उपयोगकर्ता",
"photo_label": "फ़ोटो",
"sort_label": "क्रमबद्ध करें",
"group_by_label": "इसके अनुसार समूहित करें",
"state_value_help": "HA स्थिति मान का उपयोग करें (आमतौर पर लोअरकेस, उदा. \"on\"/\"off\")। सहेजने पर केस सामान्यीकृत हो जाता है।",
@@ -722,6 +730,9 @@
"part_restock": "स्टॉक समायोजित करें",
"restock_quantity_label": "खरीदी गई मात्रा",
"consumes_parts_label": "पुर्ज़े खपत",
"shared_parts_other_objects": "अन्य वस्तुओं के पुर्ज़े",
"shared_parts_help": "कई वस्तुएँ एक ही स्टॉक साझा कर सकती हैं। यह कार्य पूरा करने पर स्वामी वस्तु के स्टॉक से घटाया जाता है।",
"shared_part_unknown": "अज्ञात पुर्ज़ा",
"parts_load_failed": "इस ऑब्जेक्ट के पुर्ज़े लोड नहीं हो सके — पुर्ज़ा-खपत विकल्प अभी उपलब्ध नहीं हैं।",
"settings_export_selection": "चयनित ऑब्जेक्ट तक सीमित करें (वैकल्पिक)",
"settings_docs_archive": "दस्तावेज़ संग्रह (फ़ाइलों सहित)",
@@ -109,6 +109,7 @@
"trigger_val": "Kiváltó érték",
"complete_title": "Elvégzés: ",
"checklist": "Ellenőrzőlista",
"require_on_completion": "Befejezéskor kötelező",
"checklist_steps_optional": "Ellenőrzőlista lépései (opcionális)",
"checklist_placeholder": "Szűrő tisztítása\nTömítés cseréje\nNyomáspróba",
"checklist_help": "Soronként egy lépés. Legfeljebb 100 elem.",
@@ -363,6 +364,8 @@
"card_filter_status_help": "Üres = minden állapot megjelenik.",
"card_filter_objects": "Szűrés objektumok szerint",
"card_filter_objects_help": "Üres = minden objektum megjelenik.",
"card_filter_areas": "Szűrés területek szerint",
"card_filter_areas_help": "Üres = minden terület megjelenik.",
"card_filter_entities": "Szűrés entitások szerint (entity_id-k)",
"card_filter_entities_help": "Válasszon sensor / binary_sensor entitásokat ebből az integrációból. Üres = mind.",
"card_loading_objects": "Objektumok betöltése…",
@@ -432,11 +435,15 @@
"settings_panel_title": "Oldalsáv-panel címe",
"settings_notifications": "Értesítések",
"settings_notify_service": "Értesítési szolgáltatás",
"settings_install_assist_sentences": "Assist mondatok telepítése",
"settings_install_assist_sentences_hint": "Átmásolja a hangmondatokat a konfigurációdba, hogy a klasszikus Assist ügynök felismerje őket. Az általad szerkesztett fájlt soha nem írja felül.",
"test_notification": "Tesztértesítés",
"send_test": "Teszt küldése",
"testing": "Küldés…",
"test_notification_success": "Tesztértesítés elküldve",
"test_notification_failed": "A tesztértesítés nem sikerült",
"notify_per_person": "Kézbesítés személyenként",
"notify_no_own_device": "Nincs saját eszköz — a háztartási szolgáltatást használja",
"settings_notify_due_soon": "Értesítés, ha hamarosan esedékes",
"settings_notify_overdue": "Értesítés lejáratkor",
"settings_notify_triggered": "Értesítés kiváltáskor",
@@ -494,6 +501,7 @@
"groupby_user": "Felhasználó szerint",
"filter_label": "Szűrő",
"user_label": "Felhasználó",
"photo_label": "Fénykép",
"sort_label": "Rendezés",
"group_by_label": "Csoportosítás",
"state_value_help": "A HA állapotértékét használja (általában kisbetűs, pl. „on”/„off”). A kis- és nagybetűk mentéskor normalizálódnak.",
@@ -728,6 +736,9 @@
"part_restock": "Készlet módosítása",
"restock_quantity_label": "Vásárolt mennyiség",
"consumes_parts_label": "Felhasznált alkatrészek",
"shared_parts_other_objects": "Más objektumok alkatrészei",
"shared_parts_help": "Több objektum is használhatja ugyanazt a készletet. A feladat elvégzése a tulajdonos objektum készletéből von le.",
"shared_part_unknown": "Ismeretlen alkatrész",
"parts_load_failed": "Az objektum alkatrészei nem tölthetők be — a felhasznált alkatrészek beállításai most nem érhetők el.",
"adopt_problem_button": "Problémaérzékelők átvétele",
"adopt_problem_title": "Problémaérzékelők átvétele",
@@ -108,6 +108,7 @@
"trigger_val": "Valore trigger",
"complete_title": "Completato: ",
"checklist": "Checklist",
"require_on_completion": "Richiedi al completamento",
"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.",
@@ -362,6 +363,8 @@
"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_areas": "Filtra per aree",
"card_filter_areas_help": "Vuoto = mostra tutte le aree.",
"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…",
@@ -431,11 +434,15 @@
"settings_panel_title": "Titolo pannello laterale",
"settings_notifications": "Notifiche",
"settings_notify_service": "Servizio di notifica",
"settings_install_assist_sentences": "Installa le frasi di Assist",
"settings_install_assist_sentences_hint": "Copia le frasi vocali nella tua configurazione affinché l'agente Assist classico le riconosca. Un file modificato da te non viene mai sovrascritto.",
"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",
"notify_per_person": "Consegna per persona",
"notify_no_own_device": "Nessun dispositivo proprio — usa il servizio della casa",
"settings_notify_due_soon": "Notifica quando in scadenza",
"settings_notify_overdue": "Notifica quando scaduto",
"settings_notify_triggered": "Notifica quando attivato",
@@ -487,6 +494,7 @@
"groupby_user": "Per utente",
"filter_label": "Filtro",
"user_label": "Utente",
"photo_label": "Foto",
"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.",
@@ -722,6 +730,9 @@
"part_restock": "Correggi scorta",
"restock_quantity_label": "Quantità acquistata",
"consumes_parts_label": "Consuma ricambi",
"shared_parts_other_objects": "Ricambi di altri oggetti",
"shared_parts_help": "Più oggetti possono condividere una sola scorta. Completando questa attività si preleva dall'oggetto proprietario.",
"shared_part_unknown": "Ricambio sconosciuto",
"parts_load_failed": "Impossibile caricare i ricambi di questo oggetto — le opzioni di consumo ricambi non sono al momento disponibili.",
"settings_export_selection": "Limita agli oggetti selezionati (facoltativo)",
"settings_docs_archive": "Archivio documenti (con file)",
@@ -109,6 +109,7 @@
"trigger_val": "トリガー値",
"complete_title": "完了: ",
"checklist": "チェックリスト",
"require_on_completion": "完了時に必須",
"checklist_steps_optional": "チェックリストの手順(任意)",
"checklist_placeholder": "フィルター清掃\nシール交換\n圧力テスト",
"checklist_help": "1行に1手順。最大100項目。",
@@ -363,6 +364,8 @@
"card_filter_status_help": "空欄=すべての状態を表示。",
"card_filter_objects": "対象で絞り込み",
"card_filter_objects_help": "空欄=すべての対象を表示。",
"card_filter_areas": "エリアで絞り込み",
"card_filter_areas_help": "空欄=すべてのエリアを表示。",
"card_filter_entities": "エンティティで絞り込み(entity_ids",
"card_filter_entities_help": "この統合のsensorbinary_sensorエンティティを選択。空欄=すべて。",
"card_loading_objects": "対象を読み込み中…",
@@ -432,11 +435,15 @@
"settings_panel_title": "サイドバーパネルのタイトル",
"settings_notifications": "通知",
"settings_notify_service": "通知サービス",
"settings_install_assist_sentences": "Assist の文を導入する",
"settings_install_assist_sentences_hint": "音声フレーズを設定に複製し、従来の Assist エージェントが認識できるようにします。自分で編集したファイルが上書きされることはありません。",
"test_notification": "テスト通知",
"send_test": "テスト送信",
"testing": "送信中…",
"test_notification_success": "テスト通知を送信しました",
"test_notification_failed": "テスト通知に失敗しました",
"notify_per_person": "メンバーごとの配信",
"notify_no_own_device": "個人の端末なし — 家庭用サービスを使用",
"settings_notify_due_soon": "期限間近で通知",
"settings_notify_overdue": "期限超過で通知",
"settings_notify_triggered": "トリガー発生で通知",
@@ -488,6 +495,7 @@
"groupby_user": "ユーザー別",
"filter_label": "絞り込み",
"user_label": "ユーザー",
"photo_label": "写真",
"sort_label": "並べ替え",
"group_by_label": "グループ化",
"state_value_help": "HAの状態値を使用してください(通常は小文字、例: \"on\"/\"off\")。保存時に大文字小文字は正規化されます。",
@@ -722,6 +730,9 @@
"part_restock": "在庫を調整",
"restock_quantity_label": "購入数量",
"consumes_parts_label": "部品を消費",
"shared_parts_other_objects": "他の対象の部品",
"shared_parts_help": "複数の対象で同じ在庫を共有できます。このタスクを完了すると、所有する対象の在庫から差し引かれます。",
"shared_part_unknown": "不明な部品",
"parts_load_failed": "このオブジェクトの部品を読み込めませんでした — 部品消費オプションは現在利用できません。",
"settings_export_selection": "選択したオブジェクトに限定(任意)",
"settings_docs_archive": "ドキュメントアーカイブ(ファイル付き)",
@@ -109,6 +109,7 @@
"trigger_val": "트리거 값",
"complete_title": "완료: ",
"checklist": "체크리스트",
"require_on_completion": "완료 시 필수 입력",
"checklist_steps_optional": "체크리스트 단계 (선택)",
"checklist_placeholder": "필터 청소\n씰 교체\n압력 테스트",
"checklist_help": "한 줄에 한 단계씩 입력하세요. 최대 100개.",
@@ -363,6 +364,8 @@
"card_filter_status_help": "비워두면 모든 상태를 표시합니다.",
"card_filter_objects": "객체로 필터",
"card_filter_objects_help": "비워두면 모든 객체를 표시합니다.",
"card_filter_areas": "구역으로 필터",
"card_filter_areas_help": "비워두면 모든 구역을 표시합니다.",
"card_filter_entities": "엔티티로 필터 (entity_ids)",
"card_filter_entities_help": "이 통합구성요소의 sensor / binary_sensor 엔티티를 선택하세요. 비워두면 전체.",
"card_loading_objects": "객체 불러오는 중…",
@@ -432,11 +435,15 @@
"settings_panel_title": "사이드바 패널 제목",
"settings_notifications": "알림",
"settings_notify_service": "알림 서비스",
"settings_install_assist_sentences": "Assist 문장 설치",
"settings_install_assist_sentences_hint": "음성 문장을 설정에 복사하여 기존 Assist 에이전트가 인식하도록 합니다. 직접 수정한 파일은 절대 덮어쓰지 않습니다.",
"test_notification": "테스트 알림",
"send_test": "테스트 보내기",
"testing": "보내는 중…",
"test_notification_success": "테스트 알림을 보냈습니다",
"test_notification_failed": "테스트 알림 전송 실패",
"notify_per_person": "사용자별 전송",
"notify_no_own_device": "개인 기기 없음 — 가정 서비스 사용",
"settings_notify_due_soon": "기한 임박 시 알림",
"settings_notify_overdue": "기한 초과 시 알림",
"settings_notify_triggered": "트리거 시 알림",
@@ -494,6 +501,7 @@
"groupby_user": "사용자별",
"filter_label": "필터",
"user_label": "사용자",
"photo_label": "사진",
"sort_label": "정렬",
"group_by_label": "그룹화",
"state_value_help": "HA 상태 값을 사용하세요(보통 소문자, 예: \"on\"/\"off\"). 대소문자는 저장 시 정규화됩니다.",
@@ -728,6 +736,9 @@
"part_restock": "재고 조정",
"restock_quantity_label": "구매 수량",
"consumes_parts_label": "사용하는 부품",
"shared_parts_other_objects": "다른 객체의 부품",
"shared_parts_help": "여러 객체가 하나의 재고를 공유할 수 있습니다. 이 작업을 완료하면 소유 객체의 재고에서 차감됩니다.",
"shared_part_unknown": "알 수 없는 부품",
"parts_load_failed": "이 객체의 부품을 불러올 수 없습니다 — 부품 사용 옵션을 지금은 쓸 수 없습니다.",
"adopt_problem_button": "문제 센서 가져오기",
"adopt_problem_title": "문제 센서 가져오기",
@@ -109,6 +109,7 @@
"trigger_val": "Utløserverdi",
"complete_title": "Fullfør: ",
"checklist": "Sjekkliste",
"require_on_completion": "Krev ved fullføring",
"checklist_steps_optional": "Sjekklistetrinn (valgfritt)",
"checklist_placeholder": "Rengjør filter\nBytt pakning\nTest trykk",
"checklist_help": "Ett trinn per linje. Maks 100 elementer.",
@@ -363,6 +364,8 @@
"card_filter_status_help": "Tom = vis alle statuser.",
"card_filter_objects": "Filtrer etter objekter",
"card_filter_objects_help": "Tom = vis alle objekter.",
"card_filter_areas": "Filtrer etter områder",
"card_filter_areas_help": "Tom = vis alle områder.",
"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…",
@@ -432,11 +435,15 @@
"settings_panel_title": "Tittel på sidepanel",
"settings_notifications": "Varsler",
"settings_notify_service": "Varslingstjeneste",
"settings_install_assist_sentences": "Installer Assist-setninger",
"settings_install_assist_sentences_hint": "Kopierer talesetningene til konfigurasjonen din slik at den klassiske Assist-agenten gjenkjenner dem. En fil du selv har endret, blir aldri overskrevet.",
"test_notification": "Testvarsel",
"send_test": "Send test",
"testing": "Sender…",
"test_notification_success": "Testvarsel sendt",
"test_notification_failed": "Testvarsel mislyktes",
"notify_per_person": "Levering per person",
"notify_no_own_device": "Ingen egen enhet — bruker husstandens tjeneste",
"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",
@@ -488,6 +495,7 @@
"groupby_user": "Etter bruker",
"filter_label": "Filter",
"user_label": "Bruker",
"photo_label": "Bilde",
"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.",
@@ -722,6 +730,9 @@
"part_restock": "Juster lager",
"restock_quantity_label": "Kjøpt mengde",
"consumes_parts_label": "Forbruker deler",
"shared_parts_other_objects": "Deler fra andre objekter",
"shared_parts_help": "Flere objekter kan dele samme lager. Når oppgaven fullføres, trekkes det fra det eiende objektet.",
"shared_part_unknown": "Ukjent del",
"parts_load_failed": "Kunne ikke laste objektets deler — forbruksvalgene er ikke tilgjengelige nå.",
"settings_export_selection": "Begrens til valgte objekter (valgfritt)",
"settings_docs_archive": "Dokumentarkiv (med filer)",
@@ -108,6 +108,7 @@
"trigger_val": "Triggerwaarde",
"complete_title": "Voltooid: ",
"checklist": "Checklist",
"require_on_completion": "Vereisen bij afronden",
"checklist_steps_optional": "Checklist-stappen (optioneel)",
"checklist_placeholder": "Filter schoonmaken\nPakking vervangen\nDruk testen",
"checklist_help": "Eén stap per regel. Max. 100 items.",
@@ -362,6 +363,8 @@
"card_filter_status_help": "Leeg = alle statussen tonen.",
"card_filter_objects": "Filteren op objecten",
"card_filter_objects_help": "Leeg = alle objecten tonen.",
"card_filter_areas": "Filteren op gebieden",
"card_filter_areas_help": "Leeg = alle gebieden 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…",
@@ -431,11 +434,15 @@
"settings_panel_title": "Titel zijbalkpaneel",
"settings_notifications": "Meldingen",
"settings_notify_service": "Meldingsservice",
"settings_install_assist_sentences": "Assist-zinnen installeren",
"settings_install_assist_sentences_hint": "Kopieert de spraakzinnen naar je configuratie zodat de klassieke Assist-agent ze herkent. Een bestand dat je zelf hebt bewerkt wordt nooit overschreven.",
"test_notification": "Testmelding",
"send_test": "Test versturen",
"testing": "Verzenden…",
"test_notification_success": "Testmelding verzonden",
"test_notification_failed": "Testmelding mislukt",
"notify_per_person": "Bezorging per persoon",
"notify_no_own_device": "Geen eigen apparaat — gebruikt de huishoudelijke dienst",
"settings_notify_due_soon": "Melding bij bijna verlopen",
"settings_notify_overdue": "Melding bij achterstallig",
"settings_notify_triggered": "Melding bij geactiveerd",
@@ -487,6 +494,7 @@
"groupby_user": "Per gebruiker",
"filter_label": "Filter",
"user_label": "Gebruiker",
"photo_label": "Foto",
"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.",
@@ -722,6 +730,9 @@
"part_restock": "Voorraad aanpassen",
"restock_quantity_label": "Gekochte hoeveelheid",
"consumes_parts_label": "Verbruikt onderdelen",
"shared_parts_other_objects": "Onderdelen van andere objecten",
"shared_parts_help": "Meerdere objecten kunnen één voorraad delen. Bij afronden wordt van het eigenaarsobject afgeboekt.",
"shared_part_unknown": "Onbekend onderdeel",
"parts_load_failed": "Kon de onderdelen van dit object niet laden — de verbruiksopties zijn nu niet beschikbaar.",
"settings_export_selection": "Beperken tot geselecteerde objecten (optioneel)",
"settings_docs_archive": "Documentenarchief (met bestanden)",
@@ -108,6 +108,7 @@
"trigger_val": "Wartość wyzwalacza",
"complete_title": "Wykonaj: ",
"checklist": "Lista kontrolna",
"require_on_completion": "Wymagaj przy zakończeniu",
"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.",
@@ -362,6 +363,8 @@
"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_areas": "Filtruj wg obszarów",
"card_filter_areas_help": "Puste = pokaż wszystkie obszary.",
"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…",
@@ -431,11 +434,15 @@
"settings_panel_title": "Tytuł panelu bocznego",
"settings_notifications": "Powiadomienia",
"settings_notify_service": "Usługa powiadomień",
"settings_install_assist_sentences": "Zainstaluj zdania Assist",
"settings_install_assist_sentences_hint": "Kopiuje zdania głosowe do Twojej konfiguracji, aby klasyczny agent Assist je rozpoznawał. Plik zmieniony przez Ciebie nigdy nie zostanie nadpisany.",
"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ę",
"notify_per_person": "Dostarczanie dla każdej osoby",
"notify_no_own_device": "Brak własnego urządzenia — używa usługi domowej",
"settings_notify_due_soon": "Powiadom gdy wkrótce",
"settings_notify_overdue": "Powiadom gdy zaległe",
"settings_notify_triggered": "Powiadom gdy wyzwolone",
@@ -487,6 +494,7 @@
"groupby_user": "Wg użytkownika",
"filter_label": "Filtr",
"user_label": "Użytkownik",
"photo_label": "Zdjęcie",
"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.",
@@ -722,6 +730,9 @@
"part_restock": "Koryguj stan",
"restock_quantity_label": "Kupiona ilość",
"consumes_parts_label": "Zużywa części",
"shared_parts_other_objects": "Części z innych obiektów",
"shared_parts_help": "Kilka obiektów może korzystać z jednego zapasu. Wykonanie tego zadania odejmuje ze stanu obiektu będącego właścicielem.",
"shared_part_unknown": "Nieznana część",
"parts_load_failed": "Nie udało się wczytać części tego obiektu — opcje zużycia części są teraz niedostępne.",
"settings_export_selection": "Ogranicz do wybranych obiektów (opcjonalnie)",
"settings_docs_archive": "Archiwum dokumentów (z plikami)",
@@ -109,6 +109,7 @@
"trigger_val": "Valor do gatilho",
"complete_title": "Concluir: ",
"checklist": "Checklist",
"require_on_completion": "Exigir ao concluir",
"checklist_steps_optional": "Etapas do checklist (opcional)",
"checklist_placeholder": "Limpar o filtro\nTrocar a vedação\nTestar a pressão",
"checklist_help": "Uma etapa por linha. Máx. 100 itens.",
@@ -363,6 +364,8 @@
"card_filter_status_help": "Vazio = mostrar todos os status.",
"card_filter_objects": "Filtrar por objetos",
"card_filter_objects_help": "Vazio = mostrar todos os objetos.",
"card_filter_areas": "Filtrar por áreas",
"card_filter_areas_help": "Vazio = mostrar todas as áreas.",
"card_filter_entities": "Filtrar por entidades (entity_ids)",
"card_filter_entities_help": "Escolha entidades sensor / binary_sensor desta integração. Vazio = todas.",
"card_loading_objects": "Carregando objetos…",
@@ -432,11 +435,15 @@
"settings_panel_title": "Título do painel na barra lateral",
"settings_notifications": "Notificações",
"settings_notify_service": "Serviço de notificação",
"settings_install_assist_sentences": "Instalar frases do Assist",
"settings_install_assist_sentences_hint": "Copia as frases de voz para a sua configuração para que o agente Assist clássico as reconheça. Um arquivo que você editou nunca é sobrescrito.",
"test_notification": "Notificação de teste",
"send_test": "Enviar teste",
"testing": "Enviando…",
"test_notification_success": "Notificação de teste enviada",
"test_notification_failed": "Falha na notificação de teste",
"notify_per_person": "Entrega por pessoa",
"notify_no_own_device": "Sem dispositivo próprio — usa o serviço da casa",
"settings_notify_due_soon": "Notificar quando vencer em breve",
"settings_notify_overdue": "Notificar quando atrasada",
"settings_notify_triggered": "Notificar quando acionada",
@@ -494,6 +501,7 @@
"groupby_user": "Por usuário",
"filter_label": "Filtro",
"user_label": "Usuário",
"photo_label": "Foto",
"sort_label": "Ordenar",
"group_by_label": "Agrupar por",
"state_value_help": "Use o valor de estado do HA (geralmente minúsculo, ex.: \"on\"/\"off\"). Maiúsculas e minúsculas são normalizadas ao salvar.",
@@ -728,6 +736,9 @@
"part_restock": "Ajustar estoque",
"restock_quantity_label": "Quantidade comprada",
"consumes_parts_label": "Consome peças",
"shared_parts_other_objects": "Peças de outros objetos",
"shared_parts_help": "Vários objetos podem compartilhar o mesmo estoque. Concluir esta tarefa desconta do objeto proprietário.",
"shared_part_unknown": "Peça desconhecida",
"parts_load_failed": "Não foi possível carregar as peças deste objeto — as opções de consumo de peças estão indisponíveis no momento.",
"adopt_problem_button": "Adotar sensores de problema",
"adopt_problem_title": "Adotar sensores de problema",
@@ -108,6 +108,7 @@
"trigger_val": "Valor do acionador",
"complete_title": "Concluída: ",
"checklist": "Lista de verificação",
"require_on_completion": "Exigir ao concluir",
"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.",
@@ -362,6 +363,8 @@
"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_areas": "Filtrar por áreas",
"card_filter_areas_help": "Vazio = mostrar todas as áreas.",
"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…",
@@ -431,11 +434,15 @@
"settings_panel_title": "Título do painel lateral",
"settings_notifications": "Notificações",
"settings_notify_service": "Serviço de notificação",
"settings_install_assist_sentences": "Instalar frases do Assist",
"settings_install_assist_sentences_hint": "Copia as frases de voz para a sua configuração para que o agente Assist clássico as reconheça. Um ficheiro que editou nunca é substituído.",
"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",
"notify_per_person": "Entrega por pessoa",
"notify_no_own_device": "Sem dispositivo próprio — usa o serviço da casa",
"settings_notify_due_soon": "Notificar quando próxima",
"settings_notify_overdue": "Notificar quando atrasada",
"settings_notify_triggered": "Notificar quando acionada",
@@ -483,6 +490,7 @@
"sort_group": "Grupo",
"filter_label": "Filtro",
"user_label": "Utilizador",
"photo_label": "Foto",
"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.",
@@ -722,6 +730,9 @@
"part_restock": "Ajustar estoque",
"restock_quantity_label": "Quantidade comprada",
"consumes_parts_label": "Consome peças",
"shared_parts_other_objects": "Peças de outros objetos",
"shared_parts_help": "Vários objetos podem partilhar o mesmo stock. Concluir esta tarefa desconta do objeto proprietário.",
"shared_part_unknown": "Peça desconhecida",
"parts_load_failed": "Não foi possível carregar as peças deste objeto — as opções de consumo de peças estão indisponíveis.",
"settings_export_selection": "Limitar aos objetos selecionados (opcional)",
"settings_docs_archive": "Arquivo de documentos (com ficheiros)",
@@ -108,6 +108,7 @@
"trigger_val": "Значение триггера",
"complete_title": "Выполнить: ",
"checklist": "Контрольный список",
"require_on_completion": "Требовать при завершении",
"checklist_steps_optional": "Шаги контрольного списка (необязательно)",
"checklist_placeholder": "Очистить фильтр\nЗаменить уплотнитель\nПроверить давление",
"checklist_help": "Один шаг на строку. Макс. 100 элементов.",
@@ -362,6 +363,8 @@
"card_filter_status_help": "Пусто = показать все статусы.",
"card_filter_objects": "Фильтровать по объектам",
"card_filter_objects_help": "Пусто = показать все объекты.",
"card_filter_areas": "Фильтровать по зонам",
"card_filter_areas_help": "Пусто = показать все зоны.",
"card_filter_entities": "Фильтровать по сущностям (entity_ids)",
"card_filter_entities_help": "Выберите сущности sensor / binary_sensor из этой интеграции. Пусто = все.",
"card_loading_objects": "Загрузка объектов…",
@@ -431,11 +434,15 @@
"settings_panel_title": "Заголовок панели",
"settings_notifications": "Уведомления",
"settings_notify_service": "Сервис уведомлений",
"settings_install_assist_sentences": "Установить фразы Assist",
"settings_install_assist_sentences_hint": "Копирует голосовые фразы в вашу конфигурацию, чтобы классический агент Assist их распознавал. Файл, который вы изменили, никогда не перезаписывается.",
"test_notification": "Тестовое уведомление",
"send_test": "Отправить тест",
"testing": "Отправка…",
"test_notification_success": "Тестовое уведомление отправлено",
"test_notification_failed": "Не удалось отправить тестовое уведомление",
"notify_per_person": "Доставка по пользователям",
"notify_no_own_device": "Нет своего устройства — используется общий сервис",
"settings_notify_due_soon": "Уведомлять, когда срок скоро истекает",
"settings_notify_overdue": "Уведомлять при просрочке",
"settings_notify_triggered": "Уведомлять при срабатывании",
@@ -487,6 +494,7 @@
"groupby_user": "По пользователю",
"filter_label": "Фильтр",
"user_label": "Пользователь",
"photo_label": "Фото",
"sort_label": "Сортировка",
"group_by_label": "Группировать по",
"state_value_help": "Используйте значение состояния HA (обычно в нижнем регистре, напр. \"on\"/\"off\"). Регистр нормализуется при сохранении.",
@@ -722,6 +730,9 @@
"part_restock": "Изменить запас",
"restock_quantity_label": "Куплено, шт.",
"consumes_parts_label": "Расходует детали",
"shared_parts_other_objects": "Детали других объектов",
"shared_parts_help": "Несколько объектов могут использовать один запас. Выполнение этой задачи списывает детали у объекта-владельца.",
"shared_part_unknown": "Неизвестная деталь",
"parts_load_failed": "Не удалось загрузить запчасти этого объекта — параметры расхода запчастей сейчас недоступны.",
"settings_export_selection": "Ограничить выбранными объектами (необязательно)",
"settings_docs_archive": "Архив документов (с файлами)",
@@ -108,6 +108,7 @@
"trigger_val": "Utlösarvärde",
"complete_title": "Slutför: ",
"checklist": "Checklista",
"require_on_completion": "Kräv vid slutförande",
"checklist_steps_optional": "Checkliststeg (valfritt)",
"checklist_placeholder": "Rengör filter\nByt tätning\nTesta tryck",
"checklist_help": "Ett steg per rad. Max 100 objekt.",
@@ -362,6 +363,8 @@
"card_filter_status_help": "Tomt = visa alla statusar.",
"card_filter_objects": "Filtrera efter objekt",
"card_filter_objects_help": "Tomt = visa alla objekt.",
"card_filter_areas": "Filtrera efter områden",
"card_filter_areas_help": "Tomt = visa alla områden.",
"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…",
@@ -431,11 +434,15 @@
"settings_panel_title": "Sidopanelens titel",
"settings_notifications": "Notifikationer",
"settings_notify_service": "Notifikationstjänst",
"settings_install_assist_sentences": "Installera Assist-meningar",
"settings_install_assist_sentences_hint": "Kopierar rösmeningarna till din konfiguration så att den klassiska Assist-agenten känner igen dem. En fil du själv har ändrat skrivs aldrig över.",
"test_notification": "Testnotifikation",
"send_test": "Skicka test",
"testing": "Skickar…",
"test_notification_success": "Testnotifikation skickad",
"test_notification_failed": "Testnotifikation misslyckades",
"notify_per_person": "Leverans per person",
"notify_no_own_device": "Ingen egen enhet — använder hushållets tjänst",
"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",
@@ -487,6 +494,7 @@
"groupby_user": "Per användare",
"filter_label": "Filter",
"user_label": "Användare",
"photo_label": "Foto",
"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.",
@@ -722,6 +730,9 @@
"part_restock": "Justera lager",
"restock_quantity_label": "Köpt antal",
"consumes_parts_label": "Förbrukar delar",
"shared_parts_other_objects": "Delar från andra objekt",
"shared_parts_help": "Flera objekt kan dela samma lager. När uppgiften slutförs dras det från det ägande objektet.",
"shared_part_unknown": "Okänd del",
"parts_load_failed": "Kunde inte läsa in objektets delar — förbrukningsalternativen är inte tillgängliga just nu.",
"settings_export_selection": "Begränsa till valda objekt (valfritt)",
"settings_docs_archive": "Dokumentarkiv (med filer)",
@@ -109,6 +109,7 @@
"trigger_val": "Tetikleme değeri",
"complete_title": "Tamamla: ",
"checklist": "Kontrol listesi",
"require_on_completion": "Tamamlarken zorunlu tut",
"checklist_steps_optional": "Kontrol listesi adımları (isteğe bağlı)",
"checklist_placeholder": "Filtreyi temizle\nContayı değiştir\nBasıncı test et",
"checklist_help": "Her satıra bir adım. En fazla 100 madde.",
@@ -363,6 +364,8 @@
"card_filter_status_help": "Boş = tüm durumları göster.",
"card_filter_objects": "Nesnelere göre filtrele",
"card_filter_objects_help": "Boş = tüm nesneleri göster.",
"card_filter_areas": "Alanlara göre filtrele",
"card_filter_areas_help": "Boş = tüm alanları göster.",
"card_filter_entities": "Varlıklara göre filtrele (entity_id)",
"card_filter_entities_help": "Bu entegrasyonun sensor / binary_sensor varlıklarını seçin. Boş = tümü.",
"card_loading_objects": "Nesneler yükleniyor…",
@@ -432,11 +435,15 @@
"settings_panel_title": "Kenar çubuğu paneli başlığı",
"settings_notifications": "Bildirimler",
"settings_notify_service": "Bildirim servisi",
"settings_install_assist_sentences": "Assist cümlelerini yükle",
"settings_install_assist_sentences_hint": "Sesli komut cümlelerini yapılandırmanıza kopyalar, böylece klasik Assist aracısı bunları tanır. Kendi düzenlediğiniz bir dosyanın üzerine asla yazılmaz.",
"test_notification": "Test bildirimi",
"send_test": "Test gönder",
"testing": "Gönderiliyor…",
"test_notification_success": "Test bildirimi gönderildi",
"test_notification_failed": "Test bildirimi başarısız oldu",
"notify_per_person": "Kişi bazında iletim",
"notify_no_own_device": "Kendi cihazı yok — ev servisini kullanır",
"settings_notify_due_soon": "Vade yaklaştığında bildir",
"settings_notify_overdue": "Geciktiğinde bildir",
"settings_notify_triggered": "Tetiklendiğinde bildir",
@@ -494,6 +501,7 @@
"groupby_user": "Kullanıcıya göre",
"filter_label": "Filtre",
"user_label": "Kullanıcı",
"photo_label": "Fotoğraf",
"sort_label": "Sırala",
"group_by_label": "Grupla",
"state_value_help": "HA durum değerini kullanın (genellikle küçük harf, örn. \"on\"/\"off\"). Büyük/küçük harf kaydederken normalleştirilir.",
@@ -728,6 +736,9 @@
"part_restock": "Stoku ayarla",
"restock_quantity_label": "Satın alınan miktar",
"consumes_parts_label": "Parça tüketir",
"shared_parts_other_objects": "Diğer nesnelerin parçaları",
"shared_parts_help": "Birden fazla nesne aynı stoğu paylaşabilir. Bu görev tamamlandığında stok, sahibi olan nesneden düşülür.",
"shared_part_unknown": "Bilinmeyen parça",
"parts_load_failed": "Bu nesnenin parçaları yüklenemedi — parça tüketimi seçenekleri şu anda kullanılamıyor.",
"adopt_problem_button": "Sorun sensörlerini devral",
"adopt_problem_title": "Sorun sensörlerini devral",
@@ -108,6 +108,7 @@
"trigger_val": "Значення тригера",
"complete_title": "Виконати: ",
"checklist": "Чекліст",
"require_on_completion": "Вимагати при завершенні",
"checklist_steps_optional": "Кроки чекліста (необов'язково)",
"checklist_placeholder": "Очистити фільтр\nЗамінити ущільнювач\nПеревірити тиск",
"checklist_help": "Один крок на рядок. Макс. 100 елементів.",
@@ -362,6 +363,8 @@
"card_filter_status_help": "Порожньо = показати всі статуси.",
"card_filter_objects": "Фільтрувати за об'єктами",
"card_filter_objects_help": "Порожньо = показати всі об'єкти.",
"card_filter_areas": "Фільтрувати за зонами",
"card_filter_areas_help": "Порожньо = показати всі зони.",
"card_filter_entities": "Фільтрувати за сутностями (entity_ids)",
"card_filter_entities_help": "Виберіть сутності sensor / binary_sensor з цієї інтеграції. Порожньо = всі.",
"card_loading_objects": "Завантаження об'єктів…",
@@ -431,11 +434,15 @@
"settings_panel_title": "Заголовок панелі",
"settings_notifications": "Сповіщення",
"settings_notify_service": "Служба сповіщень",
"settings_install_assist_sentences": "Встановити фрази Assist",
"settings_install_assist_sentences_hint": "Копіює голосові фрази до вашої конфігурації, щоб класичний агент Assist їх розпізнавав. Файл, який ви редагували, ніколи не перезаписується.",
"test_notification": "Тестове сповіщення",
"send_test": "Надіслати тест",
"testing": "Надсилання…",
"test_notification_success": "Тестове сповіщення надіслано",
"test_notification_failed": "Не вдалося надіслати тестове сповіщення",
"notify_per_person": "Доставка за особами",
"notify_no_own_device": "Немає власного пристрою — використовується загальна служба",
"settings_notify_due_soon": "Сповіщати, коли термін наближається",
"settings_notify_overdue": "Сповіщати про прострочення",
"settings_notify_triggered": "Сповіщати про спрацювання",
@@ -487,6 +494,7 @@
"groupby_user": "За користувачем",
"filter_label": "Фільтр",
"user_label": "Користувач",
"photo_label": "Фото",
"sort_label": "Сортування",
"group_by_label": "Групувати за",
"state_value_help": "Використовуйте значення стану HA (зазвичай у нижньому регістрі, напр. \"on\"/\"off\"). Регістр нормалізується при збереженні.",
@@ -722,6 +730,9 @@
"part_restock": "Змінити запас",
"restock_quantity_label": "Куплена кількість",
"consumes_parts_label": "Витрачає деталі",
"shared_parts_other_objects": "Деталі інших об'єктів",
"shared_parts_help": "Кілька об'єктів можуть використовувати один запас. Виконання цього завдання списує деталі з об'єкта-власника.",
"shared_part_unknown": "Невідома деталь",
"parts_load_failed": "Не вдалося завантажити запчастини цього об'єкта — параметри витрати запчастин зараз недоступні.",
"settings_export_selection": "Обмежити вибраними об'єктами (необов'язково)",
"settings_docs_archive": "Архів документів (з файлами)",
@@ -109,6 +109,7 @@
"trigger_val": "触发值",
"complete_title": "完成: ",
"checklist": "检查清单",
"require_on_completion": "完成时必填",
"checklist_steps_optional": "检查步骤 (可选)",
"checklist_placeholder": "清理过滤器\n更换密封圈\n测试压力",
"checklist_help": "每行一个步骤。最多 100 项。",
@@ -363,6 +364,8 @@
"card_filter_status_help": "留空则显示所有状态。",
"card_filter_objects": "按维护项过滤",
"card_filter_objects_help": "留空则显示所有维护项。",
"card_filter_areas": "按区域过滤",
"card_filter_areas_help": "留空则显示所有区域。",
"card_filter_entities": "按实体过滤 (entity_ids)",
"card_filter_entities_help": "选择该集成的传感器或二进制传感器实体。留空则显示全部。",
"card_loading_objects": "正在加载维护项…",
@@ -432,11 +435,15 @@
"settings_panel_title": "侧边栏面板标题",
"settings_notifications": "通知",
"settings_notify_service": "通知服务",
"settings_install_assist_sentences": "安装 Assist 语句",
"settings_install_assist_sentences_hint": "将语音语句复制到你的配置中,让传统 Assist 代理能够识别它们。你自己修改过的文件永远不会被覆盖。",
"test_notification": "测试通知",
"send_test": "发送测试",
"testing": "正在发送…",
"test_notification_success": "测试通知已发送",
"test_notification_failed": "测试通知发送失败",
"notify_per_person": "按成员分别投递",
"notify_no_own_device": "无个人设备 — 使用家庭通知服务",
"settings_notify_due_soon": "到期前通知提醒",
"settings_notify_overdue": "超期后通知提醒",
"settings_notify_triggered": "触发时通知提醒",
@@ -488,6 +495,7 @@
"groupby_user": "按负责人",
"filter_label": "过滤器",
"user_label": "用户",
"photo_label": "照片",
"sort_label": "排序",
"group_by_label": "分组依据",
"state_value_help": "使用 HA 状态值(通常为小写,例如 \"on\"/\"off\")。保存时大小写会自动规范化。",
@@ -722,6 +730,9 @@
"part_restock": "调整库存",
"restock_quantity_label": "购买数量",
"consumes_parts_label": "消耗配件",
"shared_parts_other_objects": "其他设备的配件",
"shared_parts_help": "多个设备可以共用同一批库存。完成此任务将从所属设备的库存中扣减。",
"shared_part_unknown": "未知配件",
"parts_load_failed": "无法加载此对象的配件 — 配件消耗选项暂时不可用。",
"settings_export_selection": "仅限所选对象(可选)",
"settings_docs_archive": "文档存档(含文件)",
@@ -32,7 +32,7 @@ import {
type CalendarEvent,
} from "./helpers/calendar-bucket";
import { calendarStyles } from "./calendar-styles";
import { sharedStyles, DEFAULT_CURRENCY_SYMBOL, t, ensureLocale, isLocaleLoaded, setDateTimePrefs } from "./styles";
import { sharedStyles, DEFAULT_CURRENCY_SYMBOL, t, ensureLocale, isLocaleLoaded, setDateTimePrefs, formatDueDays } from "./styles";
import type {
HomeAssistant,
MaintenanceObjectResponse,
@@ -267,7 +267,7 @@ export class MaintenanceCalendarCard extends LitElement {
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()})`
? ` (${formatDueDays(ev.days_until_due, L)})`
: "";
const recurEvery = ev.projected && ev.interval_days
? html`<span class="cal-event-recur">${
@@ -81,6 +81,18 @@ export class MaintenanceSupporterCardEditor extends LitElement {
this._valueChanged("filter_objects", [...current]);
}
private _toggleLabel(label: string, on: boolean): void {
const current = new Set(this._config.filter_labels || []);
if (on) current.add(label); else current.delete(label);
this._valueChanged("filter_labels", [...current]);
}
private _toggleArea(areaId: string, on: boolean): void {
const current = new Set(this._config.filter_areas || []);
if (on) current.add(areaId); else current.delete(areaId);
this._valueChanged("filter_areas", [...current]);
}
private _onEntitiesChanged = (e: CustomEvent<{ value: string[] }>): void => {
this._valueChanged("entity_ids", e.detail.value || []);
};
@@ -92,6 +104,30 @@ export class MaintenanceSupporterCardEditor extends LitElement {
const objectNames = [...this._objects]
.map((o) => o.object.name)
.sort((a, b) => a.localeCompare(b));
// Areas (C8): offer exactly the areas that actually hold an object — an
// area with nothing in it could only ever empty the card. Names resolve
// through hass.areas; an area HA no longer knows falls back to its raw id
// so an existing config stays visible (and un-checkable) instead of
// vanishing silently.
const selectedAreas = new Set(this._config.filter_areas || []);
const areaIds = [
...new Set(
this._objects
.map((o) => o.object.area_id)
.filter((a): a is string => !!a)
.concat([...selectedAreas]),
),
];
const areaName = (id: string): string => this.hass?.areas?.[id]?.name || id;
const areas = areaIds
.map((id) => ({ id, name: areaName(id) }))
.sort((a, b) => a.name.localeCompare(b.name));
const selectedLabels = new Set(this._config.filter_labels || []);
// Labels are free-form per task, so the picker offers exactly the ones in
// use — an empty list simply hides the section.
const labelNames = [
...new Set(this._objects.flatMap((o) => o.tasks.flatMap((tk) => tk.labels || []))),
].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.
@@ -152,6 +188,38 @@ export class MaintenanceSupporterCardEditor extends LitElement {
`
}
</div>
<!-- Area filter (C8): selects whole objects by the room they sit in.
Hidden while no object has an area the section would be an
empty box otherwise. -->
${areas.length ? html`
<div class="field">
<div class="field-label">${t("card_filter_areas", L)}</div>
<div class="object-list">
${areas.map((a) => html`
<label class="object-row">
<input type="checkbox"
.checked=${selectedAreas.has(a.id)}
@change=${(e: Event) => this._toggleArea(a.id, (e.target as HTMLInputElement).checked)} />
<span>${a.name}</span>
</label>
`)}
</div>
<div class="field-help">${t("card_filter_areas_help", L)}</div>
</div>` : nothing}
${labelNames.length ? html`
<div class="field">
<div class="field-label">${t("labels", L)}</div>
<div class="object-list">
${labelNames.map((name) => html`
<label class="object-row">
<input type="checkbox"
.checked=${selectedLabels.has(name)}
@change=${(e: Event) => this._toggleLabel(name, (e.target as HTMLInputElement).checked)} />
<span>${name}</span>
</label>
`)}
</div>
</div>` : nothing}
<!-- Entity-id filter (HA-native pattern). Limited to our integration's
sensor + binary_sensor entities via includeEntities so the picker
@@ -219,6 +287,14 @@ export class MaintenanceSupporterCardEditor extends LitElement {
></ha-switch>
</ha-formfield>
<ha-formfield label="${t("documents", L)}">
<ha-switch
.checked=${this._config.show_documents !== false}
@change=${(e: Event) =>
this._valueChanged("show_documents", (e.target as HTMLInputElement).checked)}
></ha-switch>
</ha-formfield>
<ha-formfield label="${t("card_compact", L)}">
<ha-switch
.checked=${this._config.compact || false}
@@ -2,7 +2,7 @@
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { sharedStyles, STATUS_COLORS, t, ensureLocale, isLocaleLoaded, setDateTimePrefs } from "./styles";
import { sharedStyles, STATUS_COLORS, t, ensureLocale, isLocaleLoaded, setDateTimePrefs, formatDueDays } from "./styles";
import type {
HomeAssistant,
MaintenanceObjectResponse,
@@ -13,6 +13,7 @@ import type {
SavedViewFilters,
} from "./types";
import { UserService } from "./user-service";
import { partsForCompletion } from "./helpers/shared-parts";
import "./maintenance-card-editor";
import "./components/complete-dialog";
import {
@@ -21,6 +22,13 @@ import {
openTaskQuickActions,
} from "./dialog-mount";
interface CardDoc {
id: string;
title: string;
kind: string;
url?: string | null;
}
interface FlatTask {
entry_id: string;
object_name: string;
@@ -39,6 +47,9 @@ export class MaintenanceSupporterCard extends LitElement {
@state() private _userNames: Record<string, string> = {};
private _userService: UserService | null = null;
private _userNamesLoaded = false;
/** entry_id → (task_id → documents) for the row chips. */
@state() private _taskDocs: Record<string, Record<string, CardDoc[]>> = {};
private _docsLoadedFor = new Set<string>();
private get _lang(): string {
return this.hass?.language || "en";
@@ -114,6 +125,15 @@ export class MaintenanceSupporterCard extends LitElement {
) {
this._loadUserNames();
}
// Documents: same shape of guard — fetch per object, once, and only for
// objects that actually have a document attached to one of their tasks.
if (this.hass && this._config.show_documents !== false) {
for (const obj of this._objects) {
if (this._docsLoadedFor.has(obj.entry_id)) continue;
if (!obj.tasks.some((tk) => (tk.document_count ?? 0) > 0)) continue;
this._loadDocuments(obj.entry_id);
}
}
if (changedProps.has("hass") && this.hass) {
if (!this._dataLoaded) {
this._dataLoaded = true;
@@ -146,12 +166,6 @@ export class MaintenanceSupporterCard extends LitElement {
await this._loadViewFilters();
}
/** Resolve display names for the assignee badge (best-effort).
*
* `users/list` is a READ-tier command, so the household members this card
* is built for can call it without admin rights. A failure (or a task
* whose user was deleted) leaves the name unresolved and the badge simply
* does not render never a raw user id. */
/** Display name of the task's responsible user, or "" when the badge must
* stay hidden (feature off, nobody assigned, or the name not resolved).
* With a rotation this is whoever is up next the pointer the engine
@@ -163,6 +177,12 @@ export class MaintenanceSupporterCard extends LitElement {
return this._userNames[id] || "";
}
/** Resolve display names for the assignee badge (best-effort).
*
* `users/list` is a READ-tier command, so the household members this card
* is built for can call it without admin rights. A failure (or a task
* whose user was deleted) leaves the name unresolved and the badge simply
* does not render never a raw user id. */
private async _loadUserNames(): Promise<void> {
this._userNamesLoaded = true;
if (!this._userService) this._userService = new UserService(this.hass);
@@ -175,6 +195,62 @@ export class MaintenanceSupporterCard extends LitElement {
}
}
/** Fetch one object's documents and index them by task.
*
* `documents/list` is READ tier, like `users/list` the household members
* this card is for may call it. A failure leaves the row without chips
* rather than breaking the card. */
private async _loadDocuments(entryId: string): Promise<void> {
this._docsLoadedFor.add(entryId);
try {
const res = (await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/documents/list",
entry_id: entryId,
})) as { documents: Array<CardDoc & { task_ids?: string[] }> };
const byTask: Record<string, CardDoc[]> = {};
for (const doc of res.documents || []) {
for (const taskId of doc.task_ids || []) {
(byTask[taskId] ||= []).push({ id: doc.id, title: doc.title, kind: doc.kind, url: doc.url });
}
}
this._taskDocs = { ...this._taskDocs, [entryId]: byTask };
} catch {
// no chips for this object; the card is unaffected otherwise
}
}
/** Chips to render on a row: linked documents plus the task's own manual
* link, which is the same "the manual is one tap away" affordance. */
private _docsFor(entryId: string, task: MaintenanceTask): CardDoc[] {
if (this._config.show_documents === false) return [];
const linked = this._taskDocs[entryId]?.[task.id] || [];
const out = [...linked];
if (task.documentation_url) {
out.push({ id: `url:${task.id}`, title: t("documentation_label", this._lang), kind: "weblink", url: task.documentation_url });
}
return out;
}
/** Open a chip: a web link directly, a stored file through a signed path
* (the same route the panel uses, so it works in the Companion app). */
private async _openDoc(doc: CardDoc): Promise<void> {
if (doc.kind === "weblink" && doc.url) {
window.open(doc.url, "_blank", "noopener");
return;
}
const win = window.open("about:blank", "_blank");
try {
const signed = await this.hass.connection.sendMessagePromise<{ path: string }>({
type: "auth/sign_path",
path: `/api/maintenance_supporter/document/${doc.id}`,
expires: 300,
});
if (win) win.location.href = new URL(signed.path, window.location.origin).href;
} catch {
if (win) win.close();
}
}
/** Resolve the configured saved view's filters (best-effort). A missing or
* deleted view degrades to "no view filter" same fallback semantics as
* the backend's notification routing, never an inexplicably empty card. */
@@ -222,6 +298,8 @@ export class MaintenanceSupporterCard extends LitElement {
entity_ids,
filter_due_min_days,
filter_due_max_days,
filter_labels,
filter_areas,
max_items,
} = this._config;
const entityFilter = entity_ids?.length ? new Set(entity_ids) : null;
@@ -238,6 +316,13 @@ export class MaintenanceSupporterCard extends LitElement {
for (const obj of this._objects) {
if (filter_objects?.length && !filter_objects.includes(obj.object.name)) continue;
// Areas (C8): object-level, so it selects whole objects like
// filter_objects does — "the tasks for this room". An object with no
// area_id can never satisfy a non-empty list.
if (filter_areas?.length) {
const areaId = obj.object.area_id;
if (!areaId || !filter_areas.includes(areaId)) continue;
}
for (const task of obj.tasks) {
// Completed one-time tasks ("done") are hidden from the active list.
if (task.is_done) continue;
@@ -245,6 +330,8 @@ export class MaintenanceSupporterCard extends LitElement {
// never shown on the Lovelace card.
if (task.archived || obj.object.archived) continue;
if (filter_status?.length && !filter_status.includes(task.status)) continue;
// Labels: a task passes when it carries at least one configured label.
if (filter_labels?.length && !(task.labels || []).some((lb) => filter_labels.includes(lb))) continue;
// entity_ids: HA-native filter — match the task's sensor or
// binary_sensor entity_id. Both fields come pre-resolved from the
// backend WS response (see _build_task_summary in websocket/__init__.py).
@@ -384,13 +471,26 @@ export class MaintenanceSupporterCard extends LitElement {
</div>`
: nothing}
</div>
${this._docsFor(entry_id, task).length
? html`<div class="doc-chips">
${this._docsFor(entry_id, task).map((doc) => html`
<button
type="button"
class="doc-chip"
title="${doc.title}"
@click=${(e: Event) => { e.stopPropagation(); void this._openDoc(doc); }}
>
<ha-icon icon=${doc.kind === "weblink" ? "mdi:link-variant" : "mdi:file-document-outline"}></ha-icon>
<span>${doc.title}</span>
</button>
`)}
</div>`
: nothing}
<div class="task-due">
${task.days_until_due !== null && task.days_until_due !== undefined
? task.days_until_due < 0
? html`<span class="overdue-text">${Math.abs(task.days_until_due)}${L.startsWith("de") ? "T" : "d"}</span>`
: task.days_until_due === 0
? t("today", L)
: `${task.days_until_due}${L.startsWith("de") ? "T" : "d"}`
? html`<span class="overdue-text">${formatDueDays(task.days_until_due, L)}</span>`
: formatDueDays(task.days_until_due, L)
: task.trigger_active
? "⚡"
: "—"}
@@ -411,13 +511,17 @@ export class MaintenanceSupporterCard extends LitElement {
dlg.adaptiveEnabled = !!task.adaptive_config?.enabled;
dlg.taskType = task.type || "";
dlg.readingUnit = (task as any).reading_unit || "";
dlg.requiredFields = task.required_completion_fields || [];
dlg.lang = L;
// #99: editable per-completion parts selection
// (skip on buy tasks — those restock instead).
const obj = this._objects.find((o) => o.entry_id === entry_id);
// #111: the list also carries the shared pools
// this task draws on, each named after its
// owner — resolving against the object's own
// parts alone left a foreign link invisible.
const isBuy = !!(task as any).part_ref;
dlg.parts = isBuy ? [] : (obj?.parts || []);
dlg.consumesParts = isBuy ? [] : ((task as any).consumes_parts || []);
dlg.parts = isBuy ? [] : partsForCompletion(task, entry_id, this._objects, L);
dlg.consumesParts = isBuy ? [] : (task.consumes_parts || []);
dlg.open();
}}
>
@@ -534,7 +638,37 @@ export class MaintenanceSupporterCard extends LitElement {
text-overflow: ellipsis;
}
.task-due { font-size: 13px; color: var(--secondary-text-color); min-width: 40px; text-align: right; }
.doc-chips {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-right: 6px;
max-width: 45%;
}
.doc-chip {
display: inline-flex;
align-items: center;
gap: 3px;
max-width: 14ch;
padding: 1px 6px;
border: 1px solid var(--divider-color, #e0e0e0);
border-radius: 10px;
background: none;
color: var(--secondary-text-color);
font: inherit;
font-size: 11px;
cursor: pointer;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.doc-chip:hover { color: var(--primary-color); border-color: var(--primary-color); }
.doc-chip ha-icon { --mdc-icon-size: 12px; width: 12px; height: 12px; }
/* nowrap: the due label is localized via formatDueDays ("5 d overdue",
"5 T überfällig") without it a narrow phone card wraps that onto a
second line and the row grows taller. The name column ellipsizes
instead, which it already does by design. */
.task-due { font-size: 13px; color: var(--secondary-text-color); min-width: 40px; text-align: right; white-space: nowrap; }
.overdue-text { color: var(--error-color); font-weight: 500; }
.complete-btn {
@@ -12,6 +12,7 @@ import { warrantyStatus } from "./helpers/warranty";
import { OBJECT_COLUMNS, DEFAULT_OBJECTS_TABLE_COLUMNS, sanitizeColumns } from "./helpers/object-columns";
import { downloadTextFile } from "./helpers/download";
import { buildTaskWorksheetHtml, type WorksheetExcerpt, type WorksheetLabels } from "./helpers/worksheet";
import { describePartLink, partsForCompletion } from "./helpers/shared-parts";
import { describeWsError } from "./ws-errors";
import { panelStyles } from "./panel-styles";
import type {
@@ -1759,17 +1760,13 @@ export class MaintenanceSupporterPanel extends LitElement {
statusLabel: (st: string) => t(st, L),
parts: t("consumes_parts_label", L),
};
// Required parts as checkable lines: qty × name (stock unit) — location.
const wsParts = obj.parts || [];
const partsLines = (task.consumes_parts || [])
.map((link) => {
const pt = wsParts.find((x) => x.id === link.part_id);
if (!pt) return "";
const stock = pt.stock !== null && pt.stock !== undefined ? ` (${pt.stock}${pt.unit ? " " + pt.unit : ""})` : "";
const loc = pt.storage_location ? `${pt.storage_location}` : "";
return `${link.quantity}× ${pt.name}${stock}${loc}`;
})
.filter(Boolean);
// Required parts as checkable lines: qty × name (owner) (stock unit) —
// location. A pool owned by another object (#111) names that object, and
// a link that resolves to nothing prints "Unknown part" rather than the
// blank line the old own-parts-only lookup produced.
const partsLines = (task.consumes_parts || []).map((link) =>
describePartLink(link, obj.entry_id, this._objects, L),
);
const html = buildTaskWorksheetHtml(
task, obj.object.name, labels,
(iso) => formatDate(iso, L),
@@ -1804,24 +1801,22 @@ export class MaintenanceSupporterPanel extends LitElement {
?.tasks.find((tsk) => tsk.id === taskId);
dlg.taskType = tk?.type || "";
dlg.readingUnit = tk?.reading_unit || "";
dlg.requiredFields = tk?.required_completion_fields || [];
// Spare parts: a buy task gets an editable restock-qty field; a consuming
// task shows what it will decrement (incl. the storage location).
const objParts = this._objects.find((o) => o.entry_id === entryId)?.parts || [];
const partById = new Map(objParts.map((pt) => [pt.id, pt]));
const refPart = tk?.part_ref ? partById.get(tk.part_ref.part_id) : undefined;
const refPart = tk?.part_ref ? objParts.find((pt) => pt.id === tk.part_ref!.part_id) : undefined;
dlg.restockDefault = tk?.part_ref ? (refPart?.restock_quantity ?? 1) : null;
dlg.consumesInfo = (tk?.consumes_parts || [])
.map((link) => {
const pt = partById.get(link.part_id);
if (!pt) return "";
const loc = pt.storage_location ? `${pt.storage_location}` : "";
const stock = pt.stock !== null && pt.stock !== undefined ? ` (${pt.stock}${pt.unit ? " " + pt.unit : ""})` : "";
return `${link.quantity}× ${pt.name}${stock}${loc}`;
})
.filter(Boolean);
// #111: a link may point at another object's pool — name that object, and
// never drop a line that fails to resolve (the old .filter(Boolean) hid it).
dlg.consumesInfo = (tk?.consumes_parts || []).map((link) =>
describePartLink(link, entryId, this._objects, this._lang),
);
// #99: editable per-completion parts selection (not on buy tasks — those
// RESTOCK via the qty field instead of consuming).
dlg.parts = tk?.part_ref ? [] : objParts;
// RESTOCK via the qty field instead of consuming). The list carries the
// object's own parts plus the shared pools this task draws on, so a foreign
// link is visible and untickable rather than silently absent.
dlg.parts = tk?.part_ref ? [] : partsForCompletion(tk, entryId, this._objects, this._lang);
dlg.consumesParts = tk?.part_ref ? [] : (tk?.consumes_parts || []);
dlg.open();
}
@@ -164,6 +164,8 @@ export interface MaintenanceTask {
labels?: string[];
assignee_pool?: string[];
rotation_strategy?: string | null;
/** Details this task demands on completion (v2.44): notes/cost/duration/photo/user. */
required_completion_fields?: string[];
earliest_completion_days?: number | null;
// v1.3.0: completion-action + quick-complete (gated by completion_actions feature)
on_complete_action?: {
@@ -236,11 +238,25 @@ export interface MaintenanceTask {
sensor_entity_id?: string | null;
binary_sensor_entity_id?: string | null;
/** Spare parts consumed by completing this task. */
consumes_parts?: Array<{ part_id: string; quantity: number }> | null;
consumes_parts?: TaskPartLink[] | null;
/** Present on an auto-created "buy" reminder: the owning part. */
part_ref?: { part_id: string } | null;
}
/** One entry of a task's `consumes_parts`.
*
* `entry_id` ABSENT = the part belongs to the task's own object. That is what
* every link written before #111 looks like and what is still written for own
* parts nothing emits an entry_id for them. PRESENT = the task draws on a
* stock pool owned by that other object (three vacuums, one box of dust bags),
* and completing the task decrements the other object's stock.
*/
export interface TaskPartLink {
part_id: string;
quantity: number;
entry_id?: string;
}
/** A spare part / consumable on an object (full definition + derived state). */
export interface MaintenancePart {
id: string;
@@ -328,6 +344,18 @@ export interface CardConfig {
// Defaults to ON — with rotations the row is the only place a household
// sees who is up next. Rows without an assignee simply render nothing.
show_assignee?: boolean;
// Labels to limit the card to (v2.44). A task passes when it carries at
// least one of them — same OR semantics as filter_status / filter_objects.
filter_labels?: string[];
// HA area ids to limit the card to (C8). A task passes when its parent
// OBJECT sits in one of these areas — same OR semantics as filter_objects,
// and ANDed with every other filter. Objects without an area never match a
// non-empty list. Empty / unset = no area filtering.
filter_areas?: string[];
// Show the task's linked documents (and its documentation link) as chips
// on the row, so the manual is one tap away. Defaults to ON; rows without
// a document render nothing extra.
show_documents?: boolean;
// Saved-view scope (v2.26): apply a saved view's task-selecting filters
// (status / user / label) ON TOP of the card's own filters. The view's
// sort/group dimensions are panel display state and are not applied here.