331 files

This commit is contained in:
Home Assistant Version Control
2026-10-04 05:26:42 +00:00
parent 22ba8b4648
commit c4d601bfba
331 changed files with 10227 additions and 50159 deletions
@@ -47,6 +47,21 @@ function setInput(el: MaintenanceCompleteDialog, index: number, value: string) {
input.dispatchEvent(new Event("input"));
}
/** The cost has its own element (#200: Cost | Credit and the amount). */
function costInput(el: MaintenanceCompleteDialog) {
return el.shadowRoot!.querySelector("ms-cost-input")! as HTMLElement & { value: string; updateComplete: Promise<unknown> };
}
function costAmount(el: MaintenanceCompleteDialog): HTMLInputElement {
return costInput(el).shadowRoot!.querySelector<HTMLInputElement>("input.amount")!;
}
function setCost(el: MaintenanceCompleteDialog, value: string) {
const input = costAmount(el);
input.value = value;
input.dispatchEvent(new Event("input"));
}
function clickComplete(el: MaintenanceCompleteDialog) {
const buttons = [...el.shadowRoot!.querySelectorAll(".dialog-actions ha-button")];
(buttons[buttons.length - 1] as HTMLElement).click();
@@ -60,8 +75,8 @@ describe("complete-dialog", () => {
});
setInput(el, 0, "oil changed");
setInput(el, 1, "12.5");
setInput(el, 2, "30");
setCost(el, "12.5");
setInput(el, 1, "30");
// Tick the second checklist step (click the checkbox; the event bubbles
// to the row's toggle handler exactly once).
const boxes = [...el.shadowRoot!.querySelectorAll<HTMLInputElement>(".checklist-item input")];
@@ -105,6 +120,34 @@ describe("complete-dialog", () => {
expect("completed_at" in msg).to.be.false;
});
it("a credit goes out as a negative cost (#200)", async () => {
// The old unit sold for 150: Credit, then the amount — a phone keypad
// has no minus key, so the switch carries the sign.
const { el, sent } = await mount();
const cost = costInput(el);
await cost.updateComplete;
cost.shadowRoot!.querySelector<HTMLButtonElement>(".kind.credit")!.click();
await cost.updateComplete;
expect(cost.shadowRoot!.querySelector(".hint")!.textContent).to.include("Money back");
setCost(el, "150");
clickComplete(el);
await new Promise((r) => setTimeout(r, 10));
const msg = sent.find((m) => m.type === "maintenance_supporter/task/complete")!;
expect(msg.cost).to.equal(-150);
});
it("notes may run over several lines (#202): a text area, sent trimmed, no Markdown promise", async () => {
const { el, sent } = await mount();
expect(el.shadowRoot!.querySelector("textarea.field-input"), "the notes field is a text area").to.exist;
// The history and the printouts show notes as plain text.
expect(el.shadowRoot!.textContent).not.to.include("Markdown");
setInput(el, 0, "Filter changed\nSeal checked\n");
clickComplete(el);
await new Promise((r) => setTimeout(r, 10));
const msg = sent.find((m) => m.type === "maintenance_supporter/task/complete")!;
expect(msg.notes, "the line break inside stays, the one at the end goes").to.equal("Filter changed\nSeal checked");
});
/** The optional backdate starts as a "Set date & time" button (#163): the
* HA datetime selector has no empty state, so the field only appears once
* the user asks for it, seeded with the current minute. */
@@ -396,10 +439,9 @@ describe("complete-dialog parts cost (#104)", () => {
expect(chip(el)!.textContent).to.include("18");
chip(el)!.click();
await el.updateComplete;
// Buy task: restock qty, notes, cost.
const cost = [...el.shadowRoot!.querySelectorAll<HTMLInputElement>(".field-input")][2];
await costInput(el).updateComplete;
// Machine value for the <input type="number"> — never profile-formatted.
expect(cost.value).to.equal("18");
expect(costAmount(el).value).to.equal("18");
expect(chip(el), "chip hides once cost is set").to.equal(null);
});
@@ -423,7 +465,7 @@ describe("complete-dialog parts cost (#104)", () => {
it("buy task: no chip once the user typed a cost themselves", async () => {
const el = await mountWithParts({ restockDefault: 1, restockUnitCost: 5 });
expect(chip(el)).to.exist;
setInput(el, 2, "3.10");
setCost(el, "3.10");
await el.updateComplete;
expect(chip(el)).to.equal(null);
});
@@ -128,8 +128,10 @@ describe("task-dialog completion-actions sections", () => {
"ms-textfield",
);
expect(qcInputs[0]?.value, "qc notes").to.equal("Quick note");
expect(qcInputs[1]?.value, "qc cost").to.equal("4.5");
expect(qcInputs[2]?.value, "qc duration").to.equal("10");
// The cost has its own element (#200: Cost | Credit and the amount).
const qcCost = sections[1].querySelector<HTMLElement & { value: string }>("ms-cost-input");
expect(qcCost?.value, "qc cost").to.equal("4.5");
expect(qcInputs[1]?.value, "qc duration").to.equal("10");
const qcSelect = sections[1].querySelector<HTMLSelectElement>("select.qc-feedback");
expect(qcSelect?.value, "qc feedback").to.equal("needed");
@@ -444,7 +444,9 @@ export class MaintenanceBatteryFleetSection extends LitElement {
: b.no_sensor
? html`<span class="bf-offline bf-nosensor">${t("battery_fleet_no_sensor", L)}</span>`
: nothing;
const type = html`<span class="bf-type">${b.quantity}× ${b.battery_type}</span>`;
// The full type as a tooltip: a long one ends in an ellipsis (D#162).
const typeText = `${b.quantity}× ${b.battery_type}`;
const type = html`<span class="bf-type" title=${typeText}>${typeText}</span>`;
const writer = this._canWrite;
const showMark = writer && (o.mark === "always" || b.no_sensor || b.can_mark_replaced);
return html`
@@ -659,7 +661,10 @@ export class MaintenanceBatteryFleetSection extends LitElement {
}
static styles = css`
/* The query container of the row layout (see "In a narrow list" below):
* the list's own width, not the screen's. */
.bf-card {
container: bf-fleet / inline-size;
background: var(--card-background-color, #fff);
border: 1px solid var(--divider-color);
border-radius: 10px;
@@ -817,9 +822,16 @@ export class MaintenanceBatteryFleetSection extends LitElement {
font-size: 12px;
font-style: italic;
}
/* Every row shares the list's columns, so ONE long type ("1× BATTERY
* PACK") widened the type column of all of them (D#162): it stops at
* 12em and ends in an ellipsis, the full text is the tooltip. */
.bf-type {
color: var(--secondary-text-color);
font-size: 13px;
max-width: 12em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bf-recharge {
color: var(--secondary-text-color);
@@ -835,18 +847,37 @@ export class MaintenanceBatteryFleetSection extends LitElement {
flex: 0 0 auto;
cursor: help;
}
/* On phones the row cannot fit name + chips + curve + bar + date in ONE
* line: the decorations yield (the percentage still carries the number)
* and the row wraps to two lines - the name spans the full width, the
* status chip moves under it (left, into the name column) and the rest
* keeps its pinned subgrid column, so type / percentage / date / eye
* stay aligned across rows. Without this the fixed max-content columns
* overflowed 400 px and the chips overlapped the wrapped names. */
@media (max-width: 640px) {
/* In a narrow list the row cannot fit name + chips + curve + bar + date
* in ONE line: the decorations yield (the percentage still carries the
* number) and the row wraps to two lines - the name spans the full
* width, the status chip moves under it (left, into the name column)
* and the rest keeps its pinned subgrid column, so type / percentage /
* date / eye stay aligned across rows. Without this the fixed
* max-content columns overflowed 400 px and the chips overlapped the
* wrapped names.
*
* The LIST's width decides, not the screen's (D#162): the Lovelace card
* sits in a dashboard column of 400-500 px on a wide screen, kept the
* one-line layout there and cut off the badge, the date and the hide
* button. 680 px of list is where the one-line rows stop fitting;
* desktop task pages (700 px and more) keep them. */
@container bf-fleet (max-width: 680px) {
.bf-spark,
.bf-bar {
display: none;
}
/* No fixed gaps: a column a phone leaves empty (status chip moved,
* curve and bar hidden) cost its 8 px gap all the same, and together
* with one long type the rows ran 25 px past a 402 px phone. Each
* occupied column brings its own 6 px instead, and the type column
* may shrink - its text ends in an ellipsis. */
.bf-rows {
column-gap: 0;
grid-template-columns: minmax(0, 1fr) max-content minmax(0, max-content) repeat(7, max-content);
}
.bf-row > :not(.bf-dev) {
margin-inline-start: 6px;
}
.bf-row {
row-gap: 2px;
}
@@ -864,12 +895,10 @@ export class MaintenanceBatteryFleetSection extends LitElement {
grid-row: 1;
justify-self: end;
}
/* The column shrinks, the ellipsis comes from the base rule (a cap in
* vw measured the screen, not the list). */
.bf-type {
grid-row: 2;
max-width: 44vw;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bf-recharge,
.bf-level,
@@ -14,6 +14,7 @@ import { REQUIRED_COMPLETION_LABELS } from "./required-completion-labels";
import { PhotoUploadController } from "../helpers/photo-upload-controller";
import { parseDurationMinutes } from "../helpers/duration";
import "./ms-date-field";
import "./ms-cost-input";
import "./ms-photo-picker";
import { photoPickerStyles } from "./ms-photo-picker";
@@ -173,10 +174,14 @@ export class MaintenanceCompleteDialog extends LitElement {
entry_id: this.entryId,
task_id: this.taskId,
};
if (this._notes) data.notes = this._notes;
// Trimmed like the required check reads it: a text area easily keeps a
// trailing line break, which the history would show as an empty line.
const notes = this._notes.trim();
if (notes) data.notes = notes;
if (this._cost) {
// #200: a credit is negative; the server holds the bounds.
const cost = parseFloat(this._cost);
if (!isNaN(cost) && cost >= 0) data.cost = cost;
if (!isNaN(cost)) data.cost = cost;
}
// Whole minutes — the server coerces to int (helpers/duration).
const dur = parseDurationMinutes(this._duration);
@@ -309,9 +314,15 @@ export class MaintenanceCompleteDialog extends LitElement {
return this.requiredFields.filter((f) => !filled[f]);
}
/** Does the task demand this detail? Its label then drops "(optional)" —
* "Notes (optional) *" contradicted itself. */
private _demands(field: string): boolean {
return this.requiredFields.includes(field);
}
/** 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;
return this._demands(field) ? html`<span class="req-mark" aria-hidden="true">*</span>` : nothing;
}
/** A buy task's price: restock qty (packages) × the part's price per
@@ -485,20 +496,22 @@ 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)}${this._req("notes")}</span>
<input type="text" class="field-input"
<span class="field-label">${this._demands("notes") ? t("notes_label", L) : t("notes_optional", L)}${this._req("notes")}</span>
<!-- Several lines (#202): what was done, one line each. The
history and the printouts keep the line breaks; they show
notes as plain text, so no Markdown hint here. -->
<textarea class="field-input notes-input" rows="3"
.value=${this._notes}
@input=${(e: Event) => (this._notes = (e.target as HTMLInputElement).value)} />
@input=${(e: Event) => (this._notes = (e.target as HTMLTextAreaElement).value)}></textarea>
</label>
<label class="field">
<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)} />
<div class="field">
<span class="field-label">${this._demands("cost") ? t("cost", L) : t("cost_optional", L)}${this._req("cost")}</span>
<ms-cost-input .lang=${L} .value=${this._cost}
@value-changed=${(e: CustomEvent<{ value: string }>) => (this._cost = e.detail.value)}></ms-cost-input>
${this._renderCostSuggestion(L)}
</label>
</div>
<label class="field">
<span class="field-label">${t("duration_minutes", L)}${this._req("duration")}</span>
<span class="field-label">${this._demands("duration") ? t("quick_complete_defaults_duration", L) : t("duration_minutes", L)}${this._req("duration")}</span>
<input type="number" step="1" min="0" inputmode="numeric" class="field-input"
.value=${this._duration}
@input=${(e: Event) => (this._duration = (e.target as HTMLInputElement).value)} />
@@ -519,7 +532,7 @@ export class MaintenanceCompleteDialog extends LitElement {
</button>`}
</div>
<div class="field">
<span class="field-label">${t("completion_photos_optional", L)}${this._req("photo")}</span>
<span class="field-label">${this._demands("photo") ? t("completion_photos", L) : t("completion_photos_optional", L)}${this._req("photo")}</span>
${this._photos.photos.length > 0
? html`<div class="photo-strip">
${this._photos.photos.map((p) => html`
@@ -582,6 +595,7 @@ export class MaintenanceCompleteDialog extends LitElement {
margin-left: 2px;
font-weight: 600;
}
.notes-input { resize: vertical; line-height: 1.4; }
/* #104: one-click cost suggestion from parts — quiet link-style chip. */
.cost-note {
font-size: 12px;
@@ -106,8 +106,10 @@ export class MaintenanceGroupDialog extends LitElement {
const L = this._lang;
const title = this._groupId ? t("edit_group", L) : t("new_group", L);
// Our own title element: Home Assistant's dialog draws no `heading`.
return html`
<ha-dialog open @closed=${this._close} heading="${title}">
<ha-dialog open @closed=${this._close}>
<div class="dialog-title">${title}</div>
<div class="content">
${this._error ? html`<div class="error">${this._error}</div>` : nothing}
<ms-textfield
@@ -169,6 +171,7 @@ export class MaintenanceGroupDialog extends LitElement {
}
static styles = css`
.dialog-title { font-size: 18px; font-weight: 500; padding-bottom: 12px; }
.content {
display: flex;
flex-direction: column;
@@ -18,6 +18,7 @@ import { PhotoUploadController } from "../helpers/photo-upload-controller";
import { parseDurationMinutes } from "../helpers/duration";
import { confirmAction } from "../helpers/confirm";
import "./ms-date-field";
import "./ms-cost-input";
import "./history-photo";
import "./ms-photo-picker";
import { photoPickerStyles } from "./ms-photo-picker";
@@ -404,15 +405,14 @@ export class MaintenanceHistoryEditDialog extends LitElement {
.value=${d.notes ?? ""}></textarea>
</label>
<div class="row">
<label>
<div class="cost-field">
<span>${t("cost", L)}</span>
<input type="number" min="0" step="0.01"
.value=${d.cost != null ? String(d.cost) : ""}
@input=${(e: Event) => {
const v = (e.target as HTMLInputElement).value;
<ms-cost-input .lang=${L} .value=${d.cost != null ? String(d.cost) : ""}
@value-changed=${(e: CustomEvent<{ value: string }>) => {
const v = e.detail.value;
this._set("cost", v ? Number(v) : null);
}} />
</label>
}}></ms-cost-input>
</div>
<label>
<span>${t("duration", L)}</span>
<input type="number" min="0" step="1" inputmode="numeric"
@@ -531,6 +531,8 @@ export class MaintenanceHistoryEditDialog extends LitElement {
}
label { display: flex; flex-direction: column; gap: 4px; font-size: 13px; }
label span { color: var(--secondary-text-color); }
.cost-field { display: flex; flex-direction: column; gap: 4px; font-size: 13px; min-width: 0; }
.cost-field > span { color: var(--secondary-text-color); }
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
input, textarea {
padding: 8px; font-size: 14px;
@@ -100,8 +100,10 @@ export class SeasonalOverridesDialog extends LitElement {
render() {
if (!this._open) return html``;
const L = this._lang;
// Our own title element: Home Assistant's dialog draws no `heading`.
return html`
<ha-dialog open @closed=${this._close} heading="${t("seasonal_overrides_title", L)}">
<ha-dialog open @closed=${this._close}>
<div class="dialog-title">${t("seasonal_overrides_title", L)}</div>
<div class="content">
<p class="hint">${t("seasonal_overrides_hint", L)}</p>
${this._error ? html`<div class="error">${this._error}</div>` : nothing}
@@ -138,6 +140,7 @@ export class SeasonalOverridesDialog extends LitElement {
}
static styles = css`
.dialog-title { font-size: 18px; font-weight: 500; padding-bottom: 12px; }
.content {
min-width: 320px;
max-width: 480px;
@@ -1240,6 +1240,14 @@ export class MaintenanceSettingsView extends LitElement {
<span class="setting-label">${t("settings_max_per_day", L)}</span>
${this._intSetting("max_notifications_per_day", n.max_per_day)}
</label>
<!-- Every Snooze uses it — the task menu, a card, voice, the phone's
button — so it is not tied to the Snooze action button (D#193:
it only showed while that button was on). -->
<label class="setting-row snooze-duration">
<span class="setting-label">${t("settings_snooze_hours", L)}</span>
${this._intSetting("snooze_duration_hours", a.snooze_duration_hours)}
</label>
<div class="setting-hint">${t("settings_snooze_hours_hint", L)}</div>
<label class="setting-row">
<span class="setting-label">${t("settings_bundling", L)}</span>
@@ -1336,12 +1344,6 @@ export class MaintenanceSettingsView extends LitElement {
<input type="checkbox" .checked=${a.snooze_enabled}
@change=${(e: Event) => this._updateSetting("action_snooze_enabled", (e.target as HTMLInputElement).checked)} />
</label>
${a.snooze_enabled ? html`
<label class="setting-row sub-row">
<span class="setting-desc">${t("settings_snooze_hours", L)}</span>
${this._intSetting("snooze_duration_hours", a.snooze_duration_hours)}
</label>
` : nothing}
<label class="setting-row">
<span class="setting-label">${t("settings_weekly_digest", L)}</span>
<input type="checkbox" .checked=${a.weekly_digest_enabled}
@@ -29,6 +29,7 @@ import { REQUIRED_COMPLETION_KEYS, REQUIRED_COMPLETION_LABELS } from "./required
import "./ms-textfield";
import { defaultNotifyIcon } from "../helpers/notify-icons";
import "./ms-date-field";
import "./ms-cost-input";
const MAINTENANCE_TYPE_KEYS = ["cleaning", "inspection", "replacement", "calibration", "service", "reading", "custom"];
const PRIORITY_KEYS = ["low", "normal", "high"];
@@ -1034,12 +1035,11 @@ export class MaintenanceTaskDialog extends LitElement {
.value=${this._qcNotes}
@input=${(e: Event) => { this._qcNotes = (e.target as HTMLInputElement).value; }}
></ms-textfield>
<ms-textfield
label="${t("quick_complete_defaults_cost", L)}"
type="number" min="0" step="0.01"
.value=${this._qcCost}
@input=${(e: Event) => { this._qcCost = (e.target as HTMLInputElement).value; }}
></ms-textfield>
<div class="qc-cost">
<span class="qc-cost-label">${t("quick_complete_defaults_cost", L)}</span>
<ms-cost-input .lang=${L} .value=${this._qcCost}
@value-changed=${(e: CustomEvent<{ value: string }>) => { this._qcCost = e.detail.value; }}></ms-cost-input>
</div>
<ms-textfield
label="${t("quick_complete_defaults_duration", L)}"
type="number" min="0" step="1"
@@ -1788,7 +1788,7 @@ export class MaintenanceTaskDialog extends LitElement {
const qcd: Record<string, unknown> = {};
if (this._qcNotes.trim()) qcd.notes = this._qcNotes.trim();
const cost = parseFloat(this._qcCost);
if (!isNaN(cost) && cost >= 0) qcd.cost = cost;
if (!isNaN(cost)) qcd.cost = cost;
const dur = parseDurationMinutes(this._qcDuration);
if (dur !== null) qcd.duration = dur;
if (this._qcFeedback) qcd.feedback = this._qcFeedback;
@@ -3588,6 +3588,8 @@ export class MaintenanceTaskDialog extends LitElement {
font-weight: 500;
color: var(--secondary-text-color);
}
.qc-cost { display: flex; flex-direction: column; gap: 4px; margin: 8px 0; }
.qc-cost-label { font-size: 12px; color: var(--secondary-text-color); }
.field-help {
font-size: 12px;
color: var(--secondary-text-color);
@@ -106,11 +106,12 @@ function addDaysIso(iso: string, days: number): string {
/** Average recorded cost across history rows that HAVE a cost (mean of
* non-null `cost` values). Note this differs from the panel KPI, which divides
* total_cost by times_performed — completions without a recorded cost are
* counted differently. This mean is used only for the calendar tooltip. */
* counted differently. This mean is used only for the calendar tooltip — what
* the next occurrence will likely cost, so a credit (#200) stays out. */
function computeAvgCost(history: ReadonlyArray<HistoryEntry> | undefined): number | null {
if (!history || history.length === 0) return null;
// What each entry counts (#104), not only the typed cost.
const costs = history.map((h) => entrySpend(h)).filter((c): c is number => c !== null);
const costs = history.map((h) => entrySpend(h)).filter((c): c is number => c !== null && c >= 0);
if (costs.length === 0) return null;
return costs.reduce((a, b) => a + b, 0) / costs.length;
}
@@ -47,6 +47,19 @@
"bulk_delete_objects_confirm": "Smazat {n} objektů včetně všech jejich úkolů? Archivace místo toho zachová historii.",
"bulk_objects_deleted": "Smazáno objektů: {n}",
"bulk_objects_archived": "Archivováno objektů: {n}",
"bulk_assign": "Přiřadit",
"bulk_assign_one": "Jedna osoba",
"bulk_assign_rotate": "Více lidí, střídavě",
"bulk_assign_hint": "Nahradí přiřazení vybraných úkolů — včetně rotace.",
"bulk_rotation_min_two": "Vyberte alespoň dvě osoby.",
"bulk_labels_add": "Přidat štítky",
"bulk_labels_remove": "Odebrat štítky",
"bulk_edit_hint": "Změní se jen zaškrtnutá nastavení, vše ostatní zůstane beze změny.",
"bulk_no_notifications": "Bez oznámení pro tyto úkoly",
"bulk_updated": "Aktualizováno {n} úkolů",
"bulk_paused": "Pozastaveno {n} úkolů",
"bulk_resumed": "Obnoveno {n} úkolů",
"bulk_objects_area": "Oblast nastavena pro objekty: {n}",
"bulk_failed": "Nezdařilo se: {n} — {reason}",
"completing": "Dokončování…",
"interval": "Interval",
@@ -114,6 +127,8 @@
"custom": "Vlastní",
"history": "Historie",
"cost": "Náklady",
"cost_kind_credit": "Dobropis",
"cost_credit_hint": "Peníze zpět — prodej, vrácení. Snižuje součty; průměr na dokončení ho nezapočítává.",
"report_button": "Zpráva",
"report_title": "Zpráva o údržbě",
"report_generated": "Vytvořeno",
@@ -670,6 +685,7 @@
"settings_warranty_reminder_days": "Dny před vypršením",
"settings_warranty_reminder_hint": "Upozornit jednou, když do vypršení záruky objektu zbývá tolik dní.",
"settings_snooze_hours": "Doba odložení (hodiny)",
"settings_snooze_hours_hint": "Jak dlouho „Odložit“ ztiší připomínky úkolu — z nabídky úkolu, z karty, hlasem nebo tlačítkem Odložit v telefonu. Termín zůstává beze změny.",
"settings_budget": "Rozpočet",
"settings_currency": "Měna",
"settings_currency_decimals": "Desetinná místa částek",
@@ -49,6 +49,19 @@
"bulk_delete_objects_confirm": "Slet {n} objekter og alle deres opgaver? Arkivering bevarer i stedet historikken.",
"bulk_objects_deleted": "{n} objekter slettet",
"bulk_objects_archived": "{n} objekter arkiveret",
"bulk_assign": "Tildel",
"bulk_assign_one": "Én person",
"bulk_assign_rotate": "Flere personer, på skift",
"bulk_assign_hint": "Erstatter, hvem de valgte opgaver er tildelt — også en rotation.",
"bulk_rotation_min_two": "Vælg mindst to personer.",
"bulk_labels_add": "Tilføj etiketter",
"bulk_labels_remove": "Fjern etiketter",
"bulk_edit_hint": "Kun de afkrydsede indstillinger ændres; alt andet forbliver som det er.",
"bulk_no_notifications": "Ingen notifikationer for disse opgaver",
"bulk_updated": "{n} opgaver opdateret",
"bulk_paused": "{n} opgaver sat på pause",
"bulk_resumed": "{n} opgaver genoptaget",
"bulk_objects_area": "Område angivet for {n} objekter",
"bulk_failed": "{n} mislykkedes: {reason}",
"completing": "Fuldfører…",
"interval": "Interval",
@@ -116,6 +129,8 @@
"custom": "Tilpasset",
"history": "Historik",
"cost": "Omkostning",
"cost_kind_credit": "Kreditering",
"cost_credit_hint": "Penge tilbage — et salg, en refusion. Sænker totalerne; gennemsnittet pr. udførelse medregner det ikke.",
"report_button": "Rapport",
"report_title": "Vedligeholdelsesrapport",
"report_generated": "Genereret",
@@ -672,6 +687,7 @@
"settings_warranty_reminder_days": "Dage før udløb",
"settings_warranty_reminder_hint": "Giv besked én gang, når et objekts garanti udløber om så mange dage.",
"settings_snooze_hours": "Udsættelsesvarighed (timer)",
"settings_snooze_hours_hint": "Hvor længe „Udsæt“ gør en opgaves påmindelser tavse — fra opgavemenuen, et kort, stemmen eller telefonens Udsæt-knap. Forfaldsdatoen forbliver uændret.",
"settings_budget": "Budget",
"settings_currency": "Valuta",
"settings_currency_decimals": "Decimaler for beløb",
@@ -49,6 +49,19 @@
"bulk_delete_objects_confirm": "{n} Objekte mit allen ihren Aufgaben löschen? Archivieren behält stattdessen die Historie.",
"bulk_objects_deleted": "{n} Objekte gelöscht",
"bulk_objects_archived": "{n} Objekte archiviert",
"bulk_assign": "Zuweisen",
"bulk_assign_one": "Eine Person",
"bulk_assign_rotate": "Mehrere Personen, abwechselnd",
"bulk_assign_hint": "Ersetzt, wem die ausgewählten Aufgaben zugewiesen sind — eine Rotation eingeschlossen.",
"bulk_rotation_min_two": "Mindestens zwei Personen auswählen.",
"bulk_labels_add": "Labels hinzufügen",
"bulk_labels_remove": "Labels entfernen",
"bulk_edit_hint": "Nur die angehakten Einstellungen ändern sich, alles andere bleibt, wie es ist.",
"bulk_no_notifications": "Keine Benachrichtigungen für diese Aufgaben",
"bulk_updated": "{n} Aufgaben aktualisiert",
"bulk_paused": "{n} Aufgaben pausiert",
"bulk_resumed": "{n} Aufgaben fortgesetzt",
"bulk_objects_area": "Bereich für {n} Objekte gesetzt",
"bulk_failed": "{n} fehlgeschlagen: {reason}",
"completing": "Wird erledigt…",
"interval": "Intervall",
@@ -116,6 +129,8 @@
"custom": "Benutzerdefiniert",
"history": "Verlauf",
"cost": "Kosten",
"cost_kind_credit": "Gutschrift",
"cost_credit_hint": "Geld zurück – ein Verkauf, eine Erstattung. Senkt die Summen; der Durchschnitt pro Erledigung lässt es weg.",
"report_button": "Bericht",
"report_title": "Wartungsbericht",
"report_generated": "Erstellt",
@@ -672,6 +687,7 @@
"settings_warranty_reminder_days": "Tage vor Ablauf",
"settings_warranty_reminder_hint": "Einmal benachrichtigen, wenn die Garantie eines Objekts so viele Tage vor Ablauf steht.",
"settings_snooze_hours": "Schlummerdauer (Stunden)",
"settings_snooze_hours_hint": "Wie lange „Schlummern“ die Erinnerungen einer Aufgabe stummschaltet — im Aufgabenmenü, auf einer Karte, per Sprache oder mit der Schlummern-Taste am Handy. Das Fälligkeitsdatum bleibt, wie es ist.",
"settings_budget": "Budget",
"settings_currency": "Währung",
"settings_currency_decimals": "Nachkommastellen für Beträge",
@@ -49,6 +49,19 @@
"bulk_delete_objects_confirm": "Delete {n} objects and all their tasks? Archiving keeps their history instead.",
"bulk_objects_deleted": "{n} objects deleted",
"bulk_objects_archived": "{n} objects archived",
"bulk_assign": "Assign",
"bulk_assign_one": "One person",
"bulk_assign_rotate": "Several people, in turns",
"bulk_assign_hint": "Replaces who is assigned to the selected tasks — a rotation included.",
"bulk_rotation_min_two": "Pick at least two people.",
"bulk_labels_add": "Add labels",
"bulk_labels_remove": "Remove labels",
"bulk_edit_hint": "Only the ticked settings change; everything else stays as it is.",
"bulk_no_notifications": "No notifications for these tasks",
"bulk_updated": "{n} tasks updated",
"bulk_paused": "{n} tasks paused",
"bulk_resumed": "{n} tasks resumed",
"bulk_objects_area": "Area set for {n} objects",
"bulk_failed": "{n} failed: {reason}",
"completing": "Completing…",
"interval": "Interval",
@@ -116,6 +129,8 @@
"custom": "Custom",
"history": "History",
"cost": "Cost",
"cost_kind_credit": "Credit",
"cost_credit_hint": "Money back — a sale, a refund. It lowers the totals; the average per completion leaves it out.",
"report_button": "Report",
"report_title": "Maintenance report",
"report_generated": "Generated",
@@ -672,6 +687,7 @@
"settings_warranty_reminder_days": "Days before expiry",
"settings_warranty_reminder_hint": "Notify once when an object's warranty is this many days from expiring.",
"settings_snooze_hours": "Snooze duration (hours)",
"settings_snooze_hours_hint": "How long Snooze mutes a task's reminders — from the task menu, a card, voice or the phone's Snooze button. The due date stays as it is.",
"settings_budget": "Budget",
"settings_currency": "Currency",
"settings_currency_decimals": "Decimal places for amounts",
@@ -47,6 +47,19 @@
"bulk_delete_objects_confirm": "¿Eliminar {n} objetos y todas sus tareas? Archivar conserva su historial.",
"bulk_objects_deleted": "{n} objetos eliminados",
"bulk_objects_archived": "{n} objetos archivados",
"bulk_assign": "Asignar",
"bulk_assign_one": "Una persona",
"bulk_assign_rotate": "Varias personas, por turnos",
"bulk_assign_hint": "Sustituye a quién están asignadas las tareas seleccionadas, incluida una rotación.",
"bulk_rotation_min_two": "Elige al menos dos personas.",
"bulk_labels_add": "Añadir etiquetas",
"bulk_labels_remove": "Quitar etiquetas",
"bulk_edit_hint": "Solo cambian los ajustes marcados; todo lo demás se queda como está.",
"bulk_no_notifications": "Sin notificaciones para estas tareas",
"bulk_updated": "{n} tareas actualizadas",
"bulk_paused": "{n} tareas pausadas",
"bulk_resumed": "{n} tareas reanudadas",
"bulk_objects_area": "Área asignada a {n} objetos",
"bulk_failed": "{n} fallidos: {reason}",
"completing": "Completando…",
"interval": "Intervalo",
@@ -114,6 +127,8 @@
"custom": "Personalizado",
"history": "Historial",
"cost": "Coste",
"cost_kind_credit": "Abono",
"cost_credit_hint": "Dinero que vuelve — una venta, un reembolso. Reduce los totales; la media por realización lo excluye.",
"report_button": "Informe",
"report_title": "Informe de mantenimiento",
"report_generated": "Generado",
@@ -670,6 +685,7 @@
"settings_warranty_reminder_days": "Días antes del vencimiento",
"settings_warranty_reminder_hint": "Notificar una vez cuando a la garantía de un objeto le queden estos días para vencer.",
"settings_snooze_hours": "Duración de posposición (horas)",
"settings_snooze_hours_hint": "Cuánto tiempo «Posponer» silencia los recordatorios de una tarea: desde el menú de la tarea, una tarjeta, la voz o el botón Posponer del teléfono. La fecha de vencimiento no cambia.",
"settings_budget": "Presupuesto",
"settings_currency": "Moneda",
"settings_currency_decimals": "Decimales de los importes",
@@ -49,6 +49,19 @@
"bulk_delete_objects_confirm": "Poistetaanko {n} kohdetta kaikkine tehtävineen? Arkistointi säilyttää historian.",
"bulk_objects_deleted": "{n} kohdetta poistettu",
"bulk_objects_archived": "{n} kohdetta arkistoitu",
"bulk_assign": "Määritä",
"bulk_assign_one": "Yksi henkilö",
"bulk_assign_rotate": "Useita henkilöitä vuorotellen",
"bulk_assign_hint": "Korvaa valittujen tehtävien määritykset — myös kierron.",
"bulk_rotation_min_two": "Valitse vähintään kaksi henkilöä.",
"bulk_labels_add": "Lisää tunnisteita",
"bulk_labels_remove": "Poista tunnisteita",
"bulk_edit_hint": "Vain valitut asetukset muuttuvat; kaikki muu pysyy ennallaan.",
"bulk_no_notifications": "Ei ilmoituksia näistä tehtävistä",
"bulk_updated": "{n} tehtävää päivitetty",
"bulk_paused": "{n} tehtävää keskeytetty",
"bulk_resumed": "{n} tehtävää jatkettu",
"bulk_objects_area": "Alue asetettu {n} kohteelle",
"bulk_failed": "{n} epäonnistui: {reason}",
"completing": "Suoritetaan…",
"interval": "Väli",
@@ -116,6 +129,8 @@
"custom": "Mukautettu",
"history": "Historia",
"cost": "Kustannus",
"cost_kind_credit": "Hyvitys",
"cost_credit_hint": "Takaisin saatu raha — myynti, palautus. Pienentää summia; keskiarvo suoritusta kohden jättää sen pois.",
"report_button": "Raportti",
"report_title": "Huoltoraportti",
"report_generated": "Luotu",
@@ -672,6 +687,7 @@
"settings_warranty_reminder_days": "Päivää ennen päättymistä",
"settings_warranty_reminder_hint": "Ilmoita kerran, kun objektin takuun päättymiseen on näin monta päivää.",
"settings_snooze_hours": "Torkun kesto (tuntia)",
"settings_snooze_hours_hint": "Kuinka kauan Torkku mykistää tehtävän muistutukset — tehtävävalikosta, kortilta, äänellä tai puhelimen Torkku-painikkeella. Eräpäivä pysyy ennallaan.",
"settings_budget": "Budjetti",
"settings_currency": "Valuutta",
"settings_currency_decimals": "Summien desimaalit",
@@ -47,6 +47,19 @@
"bulk_delete_objects_confirm": "Supprimer {n} objets et toutes leurs tâches ? L'archivage conserve leur historique.",
"bulk_objects_deleted": "{n} objets supprimés",
"bulk_objects_archived": "{n} objets archivés",
"bulk_assign": "Assigner",
"bulk_assign_one": "Une personne",
"bulk_assign_rotate": "Plusieurs personnes, à tour de rôle",
"bulk_assign_hint": "Remplace l'attribution des tâches sélectionnées — rotation comprise.",
"bulk_rotation_min_two": "Choisissez au moins deux personnes.",
"bulk_labels_add": "Ajouter des étiquettes",
"bulk_labels_remove": "Retirer des étiquettes",
"bulk_edit_hint": "Seuls les réglages cochés changent ; tout le reste reste tel quel.",
"bulk_no_notifications": "Aucune notification pour ces tâches",
"bulk_updated": "{n} tâches mises à jour",
"bulk_paused": "{n} tâches mises en pause",
"bulk_resumed": "{n} tâches reprises",
"bulk_objects_area": "Zone définie pour {n} objets",
"bulk_failed": "{n} en échec : {reason}",
"completing": "En cours…",
"interval": "Intervalle",
@@ -114,6 +127,8 @@
"custom": "Personnalisé",
"history": "Historique",
"cost": "Coût",
"cost_kind_credit": "Crédit",
"cost_credit_hint": "Argent récupéré — une vente, un remboursement. Réduit les totaux ; la moyenne par réalisation l'ignore.",
"report_button": "Rapport",
"report_title": "Rapport d'entretien",
"report_generated": "Généré",
@@ -670,6 +685,7 @@
"settings_warranty_reminder_days": "Jours avant l'expiration",
"settings_warranty_reminder_hint": "Notifier une fois lorsque la garantie d'un objet expire dans ce nombre de jours.",
"settings_snooze_hours": "Durée de report (heures)",
"settings_snooze_hours_hint": "Durée pendant laquelle « Mettre en attente » coupe les rappels d'une tâche — depuis le menu de la tâche, une carte, la voix ou le bouton du téléphone. La date d'échéance ne change pas.",
"settings_budget": "Budget",
"settings_currency": "Devise",
"settings_currency_decimals": "Décimales des montants",
@@ -49,6 +49,19 @@
"bulk_delete_objects_confirm": "{n} ऑब्जेक्ट और उनके सभी कार्य हटाएँ? संग्रहित करने से उनका इतिहास बना रहता है।",
"bulk_objects_deleted": "{n} ऑब्जेक्ट हटाए गए",
"bulk_objects_archived": "{n} ऑब्जेक्ट संग्रहित किए गए",
"bulk_assign": "असाइन करें",
"bulk_assign_one": "एक व्यक्ति",
"bulk_assign_rotate": "कई लोग, बारी-बारी से",
"bulk_assign_hint": "चुने गए कार्यों का असाइनमेंट बदल देता है — रोटेशन सहित।",
"bulk_rotation_min_two": "कम से कम दो लोग चुनें।",
"bulk_labels_add": "लेबल जोड़ें",
"bulk_labels_remove": "लेबल हटाएँ",
"bulk_edit_hint": "केवल चुनी गई सेटिंग्स बदलती हैं; बाकी सब वैसा ही रहता है।",
"bulk_no_notifications": "इन कार्यों के लिए कोई सूचना नहीं",
"bulk_updated": "{n} कार्य अपडेट किए गए",
"bulk_paused": "{n} कार्य रोके गए",
"bulk_resumed": "{n} कार्य फिर से शुरू किए गए",
"bulk_objects_area": "{n} ऑब्जेक्ट के लिए क्षेत्र सेट किया गया",
"bulk_failed": "{n} विफल: {reason}",
"completing": "पूर्ण किया जा रहा है…",
"interval": "अंतराल",
@@ -116,6 +129,8 @@
"custom": "कस्टम",
"history": "इतिहास",
"cost": "लागत",
"cost_kind_credit": "वापसी",
"cost_credit_hint": "वापस मिला पैसा — बिक्री, रिफ़ंड। यह कुल योग घटाता है; प्रति पूर्णता औसत में शामिल नहीं होता।",
"report_button": "रिपोर्ट",
"report_title": "रखरखाव रिपोर्ट",
"report_generated": "जनरेट किया गया",
@@ -672,6 +687,7 @@
"settings_warranty_reminder_days": "समाप्ति से पहले के दिन",
"settings_warranty_reminder_hint": "जब किसी वस्तु की वारंटी समाप्त होने में इतने दिन बचें तो एक बार सूचित करें।",
"settings_snooze_hours": "स्नूज़ अवधि (घंटे)",
"settings_snooze_hours_hint": "स्नूज़ किसी कार्य के रिमाइंडर कितनी देर तक बंद रखता है — कार्य मेनू, कार्ड, आवाज़ या फ़ोन के स्नूज़ बटन से। नियत तिथि नहीं बदलती।",
"settings_budget": "बजट",
"settings_currency": "मुद्रा",
"settings_currency_decimals": "राशियों के दशमलव स्थान",
@@ -49,6 +49,19 @@
"bulk_delete_objects_confirm": "Törli a(z) {n} objektumot az összes feladatával együtt? Az archiválás ehelyett megőrzi az előzményeket.",
"bulk_objects_deleted": "{n} objektum törölve",
"bulk_objects_archived": "{n} objektum archiválva",
"bulk_assign": "Hozzárendelés",
"bulk_assign_one": "Egy személy",
"bulk_assign_rotate": "Több személy, felváltva",
"bulk_assign_hint": "Lecseréli, kihez vannak rendelve a kijelölt feladatok — a rotációt is.",
"bulk_rotation_min_two": "Válassz legalább két személyt.",
"bulk_labels_add": "Címkék hozzáadása",
"bulk_labels_remove": "Címkék eltávolítása",
"bulk_edit_hint": "Csak a bejelölt beállítások változnak, minden más marad.",
"bulk_no_notifications": "Nincs értesítés ezekhez a feladatokhoz",
"bulk_updated": "{n} feladat frissítve",
"bulk_paused": "{n} feladat szüneteltetve",
"bulk_resumed": "{n} feladat folytatva",
"bulk_objects_area": "Terület beállítva {n} objektumhoz",
"bulk_failed": "{n} sikertelen: {reason}",
"completing": "Elvégzés…",
"interval": "Intervallum",
@@ -116,6 +129,8 @@
"custom": "Egyéni",
"history": "Előzmények",
"cost": "Költség",
"cost_kind_credit": "Jóváírás",
"cost_credit_hint": "Visszakapott pénz — eladás, visszatérítés. Csökkenti az összegeket; az elvégzésenkénti átlag nem számolja.",
"report_button": "Jelentés",
"report_title": "Karbantartási jelentés",
"report_generated": "Létrehozva",
@@ -672,6 +687,7 @@
"settings_warranty_reminder_days": "Nappal a lejárat előtt",
"settings_warranty_reminder_hint": "Egyszeri értesítés, amikor egy objektum garanciája ennyi napra van a lejárattól.",
"settings_snooze_hours": "Szundi időtartama (óra)",
"settings_snooze_hours_hint": "Mennyi ideig némítja a Szundi egy feladat emlékeztetőit — a feladat menüjéből, egy kártyáról, hanggal vagy a telefon Szundi gombjával. A határidő nem változik.",
"settings_budget": "Költségkeret",
"settings_currency": "Pénznem",
"settings_currency_decimals": "Összegek tizedesjegyei",
@@ -47,6 +47,19 @@
"bulk_delete_objects_confirm": "Eliminare {n} oggetti con tutte le loro attività? L'archiviazione conserva invece la cronologia.",
"bulk_objects_deleted": "{n} oggetti eliminati",
"bulk_objects_archived": "{n} oggetti archiviati",
"bulk_assign": "Assegna",
"bulk_assign_one": "Una persona",
"bulk_assign_rotate": "Più persone, a turno",
"bulk_assign_hint": "Sostituisce a chi sono assegnate le attività selezionate, rotazione compresa.",
"bulk_rotation_min_two": "Scegli almeno due persone.",
"bulk_labels_add": "Aggiungi etichette",
"bulk_labels_remove": "Rimuovi etichette",
"bulk_edit_hint": "Cambiano solo le impostazioni spuntate; tutto il resto resta com'è.",
"bulk_no_notifications": "Nessuna notifica per queste attività",
"bulk_updated": "{n} attività aggiornate",
"bulk_paused": "{n} attività in pausa",
"bulk_resumed": "{n} attività riprese",
"bulk_objects_area": "Area impostata per {n} oggetti",
"bulk_failed": "{n} non riusciti: {reason}",
"completing": "Completamento…",
"interval": "Intervallo",
@@ -114,6 +127,8 @@
"custom": "Personalizzato",
"history": "Cronologia",
"cost": "Costo",
"cost_kind_credit": "Accredito",
"cost_credit_hint": "Denaro rientrato — una vendita, un rimborso. Riduce i totali; la media per completamento lo esclude.",
"report_button": "Report",
"report_title": "Report di manutenzione",
"report_generated": "Generato",
@@ -670,6 +685,7 @@
"settings_warranty_reminder_days": "Giorni prima della scadenza",
"settings_warranty_reminder_hint": "Notifica una volta quando alla garanzia di un oggetto mancano questi giorni alla scadenza.",
"settings_snooze_hours": "Durata posticipo (ore)",
"settings_snooze_hours_hint": "Per quanto tempo «Posponi» silenzia i promemoria di un'attività — dal menu dell'attività, da una scheda, con la voce o con il pulsante del telefono. La scadenza resta invariata.",
"settings_budget": "Budget",
"settings_currency": "Valuta",
"settings_currency_decimals": "Decimali degli importi",
@@ -49,6 +49,19 @@
"bulk_delete_objects_confirm": "{n} 件のオブジェクトとそのすべてのタスクを削除しますか?アーカイブなら履歴が残ります。",
"bulk_objects_deleted": "{n} 件のオブジェクトを削除しました",
"bulk_objects_archived": "{n} 件のオブジェクトをアーカイブしました",
"bulk_assign": "割り当て",
"bulk_assign_one": "1人",
"bulk_assign_rotate": "複数人で交代制",
"bulk_assign_hint": "選択したタスクの担当者を置き換えます(ローテーションを含む)。",
"bulk_rotation_min_two": "2人以上を選んでください。",
"bulk_labels_add": "ラベルを追加",
"bulk_labels_remove": "ラベルを削除",
"bulk_edit_hint": "チェックした設定だけが変わり、それ以外はそのままです。",
"bulk_no_notifications": "これらのタスクは通知しない",
"bulk_updated": "{n} 件のタスクを更新しました",
"bulk_paused": "{n} 件のタスクを一時停止しました",
"bulk_resumed": "{n} 件のタスクを再開しました",
"bulk_objects_area": "{n} 件のオブジェクトにエリアを設定しました",
"bulk_failed": "{n} 件失敗:{reason}",
"completing": "完了処理中…",
"interval": "間隔",
@@ -116,6 +129,8 @@
"custom": "カスタム",
"history": "履歴",
"cost": "費用",
"cost_kind_credit": "収入",
"cost_credit_hint": "戻ってきたお金(売却、返金など)。合計を減らしますが、1回あたりの平均には含まれません。",
"report_button": "レポート",
"report_title": "メンテナンスレポート",
"report_generated": "作成日",
@@ -672,6 +687,7 @@
"settings_warranty_reminder_days": "期限切れ前の日数",
"settings_warranty_reminder_hint": "オブジェクトの保証期限までこの日数になったら一度通知します。",
"settings_snooze_hours": "スヌーズ時間(時間)",
"settings_snooze_hours_hint": "スヌーズでタスクのリマインダーを止める時間です。タスクのメニュー、カード、音声、スマートフォンのスヌーズボタンのどこから操作しても同じです。期日は変わりません。",
"settings_budget": "予算",
"settings_currency": "通貨",
"settings_currency_decimals": "金額の小数点以下の桁数",
@@ -49,6 +49,19 @@
"bulk_delete_objects_confirm": "개체 {n}개와 모든 작업을 삭제할까요? 보관하면 기록이 유지됩니다.",
"bulk_objects_deleted": "개체 {n}개 삭제됨",
"bulk_objects_archived": "개체 {n}개 보관됨",
"bulk_assign": "배정",
"bulk_assign_one": "한 사람",
"bulk_assign_rotate": "여러 사람, 번갈아",
"bulk_assign_hint": "선택한 작업의 담당자를 바꿉니다 — 교대 방식 포함.",
"bulk_rotation_min_two": "두 명 이상 선택하세요.",
"bulk_labels_add": "라벨 추가",
"bulk_labels_remove": "라벨 제거",
"bulk_edit_hint": "체크한 설정만 바뀌고 나머지는 그대로입니다.",
"bulk_no_notifications": "이 작업들은 알림 없음",
"bulk_updated": "작업 {n}개 업데이트됨",
"bulk_paused": "작업 {n}개 일시 중지됨",
"bulk_resumed": "작업 {n}개 재개됨",
"bulk_objects_area": "개체 {n}개의 구역을 설정함",
"bulk_failed": "{n}개 실패: {reason}",
"completing": "완료 처리 중…",
"interval": "주기",
@@ -116,6 +129,8 @@
"custom": "사용자 지정",
"history": "기록",
"cost": "비용",
"cost_kind_credit": "수입",
"cost_credit_hint": "돌려받은 돈(판매, 환불 등). 합계를 줄이며, 완료당 평균에는 포함되지 않습니다.",
"report_button": "보고서",
"report_title": "유지보수 보고서",
"report_generated": "생성일",
@@ -672,6 +687,7 @@
"settings_warranty_reminder_days": "만료 전 일수",
"settings_warranty_reminder_hint": "객체의 보증 만료가 설정한 일수만큼 남았을 때 한 번 알립니다.",
"settings_snooze_hours": "다시 알림 대기 시간 (시간)",
"settings_snooze_hours_hint": "다시 알림이 작업 알림을 끄는 시간입니다 — 작업 메뉴, 카드, 음성, 휴대폰의 다시 알림 버튼 어디에서 해도 같습니다. 기한은 바뀌지 않습니다.",
"settings_budget": "예산",
"settings_currency": "통화",
"settings_currency_decimals": "금액 소수 자릿수",
@@ -49,6 +49,19 @@
"bulk_delete_objects_confirm": "Slette {n} objekter og alle oppgavene deres? Arkivering beholder historikken i stedet.",
"bulk_objects_deleted": "{n} objekter slettet",
"bulk_objects_archived": "{n} objekter arkivert",
"bulk_assign": "Tildel",
"bulk_assign_one": "Én person",
"bulk_assign_rotate": "Flere personer, på omgang",
"bulk_assign_hint": "Erstatter hvem de valgte oppgavene er tildelt — også en rotasjon.",
"bulk_rotation_min_two": "Velg minst to personer.",
"bulk_labels_add": "Legg til etiketter",
"bulk_labels_remove": "Fjern etiketter",
"bulk_edit_hint": "Bare de avkryssede innstillingene endres; alt annet forblir som det er.",
"bulk_no_notifications": "Ingen varsler for disse oppgavene",
"bulk_updated": "{n} oppgaver oppdatert",
"bulk_paused": "{n} oppgaver satt på pause",
"bulk_resumed": "{n} oppgaver gjenopptatt",
"bulk_objects_area": "Område satt for {n} objekter",
"bulk_failed": "{n} mislyktes: {reason}",
"completing": "Fullfører…",
"interval": "Intervall",
@@ -116,6 +129,8 @@
"custom": "Egendefinert",
"history": "Historikk",
"cost": "Kostnad",
"cost_kind_credit": "Kreditering",
"cost_credit_hint": "Penger tilbake — et salg, en refusjon. Senker summene; snittet per utførelse tar det ikke med.",
"report_button": "Rapport",
"report_title": "Vedlikeholdsrapport",
"report_generated": "Generert",
@@ -672,6 +687,7 @@
"settings_warranty_reminder_days": "Dager før utløp",
"settings_warranty_reminder_hint": "Varsle én gang når garantien til et objekt utløper om så mange dager.",
"settings_snooze_hours": "Slumrevarighet (timer)",
"settings_snooze_hours_hint": "Hvor lenge «Slumre» demper en oppgaves påminnelser — fra oppgavemenyen, et kort, stemmen eller telefonens Slumre-knapp. Forfallsdatoen forblir uendret.",
"settings_budget": "Budsjett",
"settings_currency": "Valuta",
"settings_currency_decimals": "Desimaler for beløp",
@@ -47,6 +47,19 @@
"bulk_delete_objects_confirm": "{n} objecten met al hun taken verwijderen? Archiveren bewaart in plaats daarvan de geschiedenis.",
"bulk_objects_deleted": "{n} objecten verwijderd",
"bulk_objects_archived": "{n} objecten gearchiveerd",
"bulk_assign": "Toewijzen",
"bulk_assign_one": "Eén persoon",
"bulk_assign_rotate": "Meerdere personen, om de beurt",
"bulk_assign_hint": "Vervangt aan wie de geselecteerde taken zijn toegewezen — een rotatie inbegrepen.",
"bulk_rotation_min_two": "Kies ten minste twee personen.",
"bulk_labels_add": "Labels toevoegen",
"bulk_labels_remove": "Labels verwijderen",
"bulk_edit_hint": "Alleen de aangevinkte instellingen veranderen; al het andere blijft zoals het is.",
"bulk_no_notifications": "Geen notificaties voor deze taken",
"bulk_updated": "{n} taken bijgewerkt",
"bulk_paused": "{n} taken gepauzeerd",
"bulk_resumed": "{n} taken hervat",
"bulk_objects_area": "Gebied ingesteld voor {n} objecten",
"bulk_failed": "{n} mislukt: {reason}",
"completing": "Wordt voltooid…",
"interval": "Interval",
@@ -114,6 +127,8 @@
"custom": "Aangepast",
"history": "Geschiedenis",
"cost": "Kosten",
"cost_kind_credit": "Tegoed",
"cost_credit_hint": "Geld terug — een verkoop, een terugbetaling. Verlaagt de totalen; het gemiddelde per voltooiing laat het weg.",
"report_button": "Rapport",
"report_title": "Onderhoudsrapport",
"report_generated": "Gegenereerd",
@@ -670,6 +685,7 @@
"settings_warranty_reminder_days": "Dagen vóór afloop",
"settings_warranty_reminder_hint": "Eén keer melden wanneer de garantie van een object over zoveel dagen afloopt.",
"settings_snooze_hours": "Snoozeduur (uren)",
"settings_snooze_hours_hint": "Hoe lang ‘Sluimeren’ de herinneringen van een taak dempt — vanuit het taakmenu, een kaart, spraak of de sluimerknop op de telefoon. De vervaldatum blijft zoals hij is.",
"settings_budget": "Budget",
"settings_currency": "Valuta",
"settings_currency_decimals": "Decimalen voor bedragen",
@@ -47,6 +47,19 @@
"bulk_delete_objects_confirm": "Usunąć {n} obiektów wraz ze wszystkimi zadaniami? Archiwizacja zachowa ich historię.",
"bulk_objects_deleted": "Usunięto obiektów: {n}",
"bulk_objects_archived": "Zarchiwizowano obiektów: {n}",
"bulk_assign": "Przypisz",
"bulk_assign_one": "Jedna osoba",
"bulk_assign_rotate": "Kilka osób, na zmianę",
"bulk_assign_hint": "Zastępuje przypisanie wybranych zadań — łącznie z rotacją.",
"bulk_rotation_min_two": "Wybierz co najmniej dwie osoby.",
"bulk_labels_add": "Dodaj etykiety",
"bulk_labels_remove": "Usuń etykiety",
"bulk_edit_hint": "Zmieniają się tylko zaznaczone ustawienia; wszystko inne pozostaje bez zmian.",
"bulk_no_notifications": "Bez powiadomień dla tych zadań",
"bulk_updated": "Zaktualizowano {n} zadań",
"bulk_paused": "Wstrzymano {n} zadań",
"bulk_resumed": "Wznowiono {n} zadań",
"bulk_objects_area": "Ustawiono obszar dla obiektów: {n}",
"bulk_failed": "Nieudane: {n} — {reason}",
"completing": "Wykonywanie…",
"interval": "Interwał",
@@ -114,6 +127,8 @@
"custom": "Niestandardowy",
"history": "Historia",
"cost": "Koszt",
"cost_kind_credit": "Zwrot",
"cost_credit_hint": "Odzyskane pieniądze — sprzedaż, zwrot. Obniża sumy; średnia na wykonanie go pomija.",
"report_button": "Raport",
"report_title": "Raport konserwacji",
"report_generated": "Wygenerowano",
@@ -670,6 +685,7 @@
"settings_warranty_reminder_days": "Dni przed wygaśnięciem",
"settings_warranty_reminder_hint": "Powiadom raz, gdy do wygaśnięcia gwarancji obiektu pozostanie tyle dni.",
"settings_snooze_hours": "Czas drzemki (godziny)",
"settings_snooze_hours_hint": "Jak długo „Drzemka” wycisza przypomnienia zadania — z menu zadania, z karty, głosem lub przyciskiem Drzemka w telefonie. Termin pozostaje bez zmian.",
"settings_budget": "Budżet",
"settings_currency": "Waluta",
"settings_currency_decimals": "Miejsca po przecinku w kwotach",
@@ -49,6 +49,19 @@
"bulk_delete_objects_confirm": "Excluir {n} objetos e todas as suas tarefas? Arquivar mantém o histórico.",
"bulk_objects_deleted": "{n} objetos excluídos",
"bulk_objects_archived": "{n} objetos arquivados",
"bulk_assign": "Atribuir",
"bulk_assign_one": "Uma pessoa",
"bulk_assign_rotate": "Várias pessoas, em rodízio",
"bulk_assign_hint": "Substitui a quem as tarefas selecionadas estão atribuídas — incluindo um rodízio.",
"bulk_rotation_min_two": "Escolha pelo menos duas pessoas.",
"bulk_labels_add": "Adicionar etiquetas",
"bulk_labels_remove": "Remover etiquetas",
"bulk_edit_hint": "Só as configurações marcadas mudam; todo o resto continua como está.",
"bulk_no_notifications": "Sem notificações para estas tarefas",
"bulk_updated": "{n} tarefas atualizadas",
"bulk_paused": "{n} tarefas pausadas",
"bulk_resumed": "{n} tarefas retomadas",
"bulk_objects_area": "Área definida para {n} objetos",
"bulk_failed": "{n} falharam: {reason}",
"completing": "Concluindo…",
"interval": "Intervalo",
@@ -116,6 +129,8 @@
"custom": "Personalizado",
"history": "Histórico",
"cost": "Custo",
"cost_kind_credit": "Crédito",
"cost_credit_hint": "Dinheiro de volta — uma venda, um reembolso. Reduz os totais; a média por conclusão não o conta.",
"report_button": "Relatório",
"report_title": "Relatório de manutenção",
"report_generated": "Gerado em",
@@ -672,6 +687,7 @@
"settings_warranty_reminder_days": "Dias antes do vencimento",
"settings_warranty_reminder_hint": "Notifica uma vez quando faltar este número de dias para a garantia de um objeto expirar.",
"settings_snooze_hours": "Duração da soneca (horas)",
"settings_snooze_hours_hint": "Por quanto tempo a Soneca silencia os lembretes de uma tarefa — pelo menu da tarefa, por um cartão, pela voz ou pelo botão Soneca do celular. A data de vencimento não muda.",
"settings_budget": "Orçamento",
"settings_currency": "Moeda",
"settings_currency_decimals": "Casas decimais dos valores",
@@ -47,6 +47,19 @@
"bulk_delete_objects_confirm": "Eliminar {n} objetos e todas as suas tarefas? Arquivar mantém o histórico.",
"bulk_objects_deleted": "{n} objetos eliminados",
"bulk_objects_archived": "{n} objetos arquivados",
"bulk_assign": "Atribuir",
"bulk_assign_one": "Uma pessoa",
"bulk_assign_rotate": "Várias pessoas, à vez",
"bulk_assign_hint": "Substitui a quem as tarefas selecionadas estão atribuídas — incluindo uma rotação.",
"bulk_rotation_min_two": "Escolha pelo menos duas pessoas.",
"bulk_labels_add": "Adicionar etiquetas",
"bulk_labels_remove": "Remover etiquetas",
"bulk_edit_hint": "Só as definições assinaladas mudam; tudo o resto fica como está.",
"bulk_no_notifications": "Sem notificações para estas tarefas",
"bulk_updated": "{n} tarefas atualizadas",
"bulk_paused": "{n} tarefas pausadas",
"bulk_resumed": "{n} tarefas retomadas",
"bulk_objects_area": "Área definida para {n} objetos",
"bulk_failed": "{n} falharam: {reason}",
"completing": "A concluir…",
"interval": "Intervalo",
@@ -114,6 +127,8 @@
"custom": "Personalizado",
"history": "Histórico",
"cost": "Custo",
"cost_kind_credit": "Crédito",
"cost_credit_hint": "Dinheiro que regressa — uma venda, um reembolso. Reduz os totais; a média por conclusão não o conta.",
"report_button": "Relatório",
"report_title": "Relatório de manutenção",
"report_generated": "Gerado",
@@ -670,6 +685,7 @@
"settings_warranty_reminder_days": "Dias antes do fim",
"settings_warranty_reminder_hint": "Notificar uma vez quando faltarem estes dias para a garantia de um objeto expirar.",
"settings_snooze_hours": "Duração do adiamento (horas)",
"settings_snooze_hours_hint": "Durante quanto tempo «Adiar» silencia os lembretes de uma tarefa — a partir do menu da tarefa, de um cartão, por voz ou do botão Adiar do telemóvel. A data limite mantém-se.",
"settings_budget": "Orçamento",
"settings_currency": "Moeda",
"settings_currency_decimals": "Casas decimais dos montantes",
@@ -47,6 +47,19 @@
"bulk_delete_objects_confirm": "Удалить {n} объектов со всеми их задачами? Архивация вместо этого сохранит историю.",
"bulk_objects_deleted": "Удалено объектов: {n}",
"bulk_objects_archived": "Архивировано объектов: {n}",
"bulk_assign": "Назначить",
"bulk_assign_one": "Один человек",
"bulk_assign_rotate": "Несколько человек по очереди",
"bulk_assign_hint": "Заменяет назначение выбранных задач — включая ротацию.",
"bulk_rotation_min_two": "Выберите не менее двух человек.",
"bulk_labels_add": "Добавить метки",
"bulk_labels_remove": "Удалить метки",
"bulk_edit_hint": "Меняются только отмеченные настройки, всё остальное остаётся как есть.",
"bulk_no_notifications": "Без уведомлений для этих задач",
"bulk_updated": "Обновлено задач: {n}",
"bulk_paused": "Приостановлено задач: {n}",
"bulk_resumed": "Возобновлено задач: {n}",
"bulk_objects_area": "Зона задана для объектов: {n}",
"bulk_failed": "Не удалось: {n} — {reason}",
"completing": "Выполнение…",
"interval": "Интервал",
@@ -114,6 +127,8 @@
"custom": "Своё",
"history": "История",
"cost": "Стоимость",
"cost_kind_credit": "Возврат",
"cost_credit_hint": "Деньги вернулись — продажа, возврат. Уменьшает итоги; среднее за выполнение его не учитывает.",
"report_button": "Отчёт",
"report_title": "Отчёт об обслуживании",
"report_generated": "Создан",
@@ -670,6 +685,7 @@
"settings_warranty_reminder_days": "Дней до окончания",
"settings_warranty_reminder_hint": "Уведомить один раз, когда до окончания гарантии объекта останется столько дней.",
"settings_snooze_hours": "Длительность откладывания (часы)",
"settings_snooze_hours_hint": "Как долго «Отложить» глушит напоминания задачи — из меню задачи, с карточки, голосом или кнопкой «Отложить» на телефоне. Срок выполнения не меняется.",
"settings_budget": "Бюджет",
"settings_currency": "Валюта",
"settings_currency_decimals": "Знаков после запятой в суммах",
@@ -47,6 +47,19 @@
"bulk_delete_objects_confirm": "Ta bort {n} objekt och alla deras uppgifter? Arkivering behåller historiken i stället.",
"bulk_objects_deleted": "{n} objekt borttagna",
"bulk_objects_archived": "{n} objekt arkiverade",
"bulk_assign": "Tilldela",
"bulk_assign_one": "En person",
"bulk_assign_rotate": "Flera personer, turvis",
"bulk_assign_hint": "Ersätter vem de valda uppgifterna är tilldelade — även en rotation.",
"bulk_rotation_min_two": "Välj minst två personer.",
"bulk_labels_add": "Lägg till etiketter",
"bulk_labels_remove": "Ta bort etiketter",
"bulk_edit_hint": "Bara de markerade inställningarna ändras; allt annat förblir som det är.",
"bulk_no_notifications": "Inga aviseringar för de här uppgifterna",
"bulk_updated": "{n} uppgifter uppdaterade",
"bulk_paused": "{n} uppgifter pausade",
"bulk_resumed": "{n} uppgifter återupptagna",
"bulk_objects_area": "Område angivet för {n} objekt",
"bulk_failed": "{n} misslyckades: {reason}",
"completing": "Slutför…",
"interval": "Intervall",
@@ -114,6 +127,8 @@
"custom": "Anpassad",
"history": "Historik",
"cost": "Kostnad",
"cost_kind_credit": "Kreditering",
"cost_credit_hint": "Pengar tillbaka — en försäljning, en återbetalning. Sänker summorna; snittet per utförande räknar inte med det.",
"report_button": "Rapport",
"report_title": "Underhållsrapport",
"report_generated": "Genererad",
@@ -670,6 +685,7 @@
"settings_warranty_reminder_days": "Dagar före utgång",
"settings_warranty_reminder_hint": "Meddela en gång när ett objekts garanti har så här många dagar kvar.",
"settings_snooze_hours": "Snooza-tid (timmar)",
"settings_snooze_hours_hint": "Hur länge ”Snooza” tystar en uppgifts påminnelser — från uppgiftsmenyn, ett kort, rösten eller telefonens Snooza-knapp. Förfallodatumet ändras inte.",
"settings_budget": "Budget",
"settings_currency": "Valuta",
"settings_currency_decimals": "Decimaler för belopp",
@@ -49,6 +49,19 @@
"bulk_delete_objects_confirm": "{n} nesne ve tüm görevleri silinsin mi? Arşivlemek geçmişi korur.",
"bulk_objects_deleted": "{n} nesne silindi",
"bulk_objects_archived": "{n} nesne arşivlendi",
"bulk_assign": "Ata",
"bulk_assign_one": "Bir kişi",
"bulk_assign_rotate": "Birden çok kişi, sırayla",
"bulk_assign_hint": "Seçili görevlerin kime atandığını değiştirir — rotasyon dahil.",
"bulk_rotation_min_two": "En az iki kişi seçin.",
"bulk_labels_add": "Etiket ekle",
"bulk_labels_remove": "Etiketleri kaldır",
"bulk_edit_hint": "Yalnızca işaretli ayarlar değişir; geri kalan her şey olduğu gibi kalır.",
"bulk_no_notifications": "Bu görevler için bildirim yok",
"bulk_updated": "{n} görev güncellendi",
"bulk_paused": "{n} görev duraklatıldı",
"bulk_resumed": "{n} göreve devam edildi",
"bulk_objects_area": "{n} nesne için alan ayarlandı",
"bulk_failed": "{n} başarısız: {reason}",
"completing": "Tamamlanıyor…",
"interval": "Aralık",
@@ -116,6 +129,8 @@
"custom": "Özel",
"history": "Geçmiş",
"cost": "Maliyet",
"cost_kind_credit": "İade",
"cost_credit_hint": "Geri gelen para — bir satış, bir iade. Toplamları düşürür; tamamlama başına ortalamaya katılmaz.",
"report_button": "Rapor",
"report_title": "Bakım raporu",
"report_generated": "Oluşturulma",
@@ -672,6 +687,7 @@
"settings_warranty_reminder_days": "Bitişten gün önce",
"settings_warranty_reminder_hint": "Bir nesnenin garantisinin bitmesine bu kadar gün kaldığında bir kez bildir.",
"settings_snooze_hours": "Erteleme süresi (saat)",
"settings_snooze_hours_hint": "Ertele'nin bir görevin hatırlatmalarını ne kadar süre sessize aldığı — görev menüsünden, bir karttan, sesle veya telefondaki Ertele düğmesiyle. Son tarih değişmez.",
"settings_budget": "Bütçe",
"settings_currency": "Para birimi",
"settings_currency_decimals": "Tutarlarda ondalık basamak",
@@ -47,6 +47,19 @@
"bulk_delete_objects_confirm": "Видалити {n} об'єктів з усіма їхніми завданнями? Архівування натомість збереже історію.",
"bulk_objects_deleted": "Видалено об'єктів: {n}",
"bulk_objects_archived": "Архівовано об'єктів: {n}",
"bulk_assign": "Призначити",
"bulk_assign_one": "Одна людина",
"bulk_assign_rotate": "Кілька людей по черзі",
"bulk_assign_hint": "Замінює призначення вибраних завдань — разом із ротацією.",
"bulk_rotation_min_two": "Виберіть щонайменше двох людей.",
"bulk_labels_add": "Додати мітки",
"bulk_labels_remove": "Вилучити мітки",
"bulk_edit_hint": "Змінюються лише позначені налаштування, усе інше лишається як є.",
"bulk_no_notifications": "Без сповіщень для цих завдань",
"bulk_updated": "Оновлено завдань: {n}",
"bulk_paused": "Призупинено завдань: {n}",
"bulk_resumed": "Відновлено завдань: {n}",
"bulk_objects_area": "Зону задано для об'єктів: {n}",
"bulk_failed": "Не вдалося: {n} — {reason}",
"completing": "Виконується…",
"interval": "Інтервал",
@@ -114,6 +127,8 @@
"custom": "Власний",
"history": "Історія",
"cost": "Вартість",
"cost_kind_credit": "Повернення",
"cost_credit_hint": "Гроші повернулися — продаж, відшкодування. Зменшує підсумки; середнє за виконання його не враховує.",
"report_button": "Звіт",
"report_title": "Звіт про обслуговування",
"report_generated": "Створено",
@@ -670,6 +685,7 @@
"settings_warranty_reminder_days": "Днів до завершення",
"settings_warranty_reminder_hint": "Сповістити один раз, коли до завершення гарантії об'єкта залишиться стільки днів.",
"settings_snooze_hours": "Тривалість відкладення (години)",
"settings_snooze_hours_hint": "Як довго «Відкласти» вимикає нагадування завдання — з меню завдання, картки, голосом або кнопкою «Відкласти» на телефоні. Термін не змінюється.",
"settings_budget": "Бюджет",
"settings_currency": "Валюта",
"settings_currency_decimals": "Знаків після коми в сумах",
@@ -49,6 +49,19 @@
"bulk_delete_objects_confirm": "删除 {n} 个对象及其全部任务?归档则会保留其历史记录。",
"bulk_objects_deleted": "已删除 {n} 个对象",
"bulk_objects_archived": "已归档 {n} 个对象",
"bulk_assign": "分配",
"bulk_assign_one": "一个人",
"bulk_assign_rotate": "多人轮流",
"bulk_assign_hint": "替换所选任务的负责人——包括轮换。",
"bulk_rotation_min_two": "请至少选择两个人。",
"bulk_labels_add": "添加标签",
"bulk_labels_remove": "移除标签",
"bulk_edit_hint": "只有勾选的设置会改变,其余保持不变。",
"bulk_no_notifications": "这些任务不发送通知",
"bulk_updated": "已更新 {n} 个任务",
"bulk_paused": "已暂停 {n} 个任务",
"bulk_resumed": "已继续 {n} 个任务",
"bulk_objects_area": "已为 {n} 个对象设置区域",
"bulk_failed": "{n} 项失败:{reason}",
"completing": "正在完成…",
"interval": "间隔",
@@ -116,6 +129,8 @@
"custom": "自定义",
"history": "历史记录",
"cost": "成本",
"cost_kind_credit": "收入",
"cost_credit_hint": "回收的钱——出售、退款。会降低总计;每次完成的平均值不计入。",
"report_button": "报告",
"report_title": "维护报告",
"report_generated": "生成于",
@@ -672,6 +687,7 @@
"settings_warranty_reminder_days": "到期前天数",
"settings_warranty_reminder_hint": "当物品保修距到期还有这么多天时通知一次。",
"settings_snooze_hours": "稍后提醒间隔 (小时)",
"settings_snooze_hours_hint": "“稍后提醒”让任务提醒静音的时长——无论来自任务菜单、卡片、语音还是手机上的稍后提醒按钮。截止日期保持不变。",
"settings_budget": "预算",
"settings_currency": "货币单位",
"settings_currency_decimals": "金额小数位数",
@@ -438,7 +438,7 @@ export class MaintenanceCalendarCard extends LitElement {
${predictionSubtitle}
${recurEvery}
</div>
${ev.avg_cost != null && ev.avg_cost > 0
${ev.avg_cost != null && ev.avg_cost !== 0
? html`<span class="cal-event-cost">${formatCost(ev.avg_cost, currencySymbol, L)}</span>`
: nothing}
</div>
@@ -104,6 +104,7 @@ import type {
} from "./components/history-edit-dialog";
import "./components/confirm-dialog";
import type { MaintenanceConfirmDialog } from "./components/confirm-dialog";
import type { BulkChanges, BulkMode, MaintenanceBulkEditDialog } from "./components/bulk-edit-dialog";
import "./components/storage-section-card";
import "./components/seasonal-overrides-dialog";
import type { SeasonalOverridesDialog } from "./components/seasonal-overrides-dialog";
@@ -130,7 +131,7 @@ import { renderStatusBadge } from "./renderers/status";
import { TOAST_MS, ACTION_TOAST_MS } from "./helpers/toast";
import { buildHistoryEntryDraft } from "./helpers/history-draft";
import { readingSlotDelta } from "./helpers/reading-slots";
import { bulkResultMessage, runWs, runWsEach } from "./helpers/ws-run";
import { bulkResultMessage, codedFailures, runWs, runWsEach } from "./helpers/ws-run";
import { snoozedMessage, type SnoozeResult } from "./helpers/snooze";
import { STATUS_ORDER, statusRank } from "./status-constants";
@@ -451,6 +452,7 @@ export class MaintenanceSupporterPanel extends LitElement {
import("./components/adopt-problem-sensors-dialog"),
import("./components/suggested-setups-dialog"),
import("./components/settings-view"),
import("./components/bulk-edit-dialog"),
]).then(() => this.updateComplete);
}
return this._lazyUi;
@@ -2269,10 +2271,15 @@ export class MaintenanceSupporterPanel extends LitElement {
/** One WS call per selected object, sequentially (config entries are
* removed one at a time on the backend). */
private _runObjBulk(type: string, doneMsg: (n: number) => string, undo?: (ids: string[]) => Promise<void>): Promise<void> {
private _runObjBulk(
type: string,
doneMsg: (n: number) => string,
undo?: (ids: string[]) => Promise<void>,
extra: Record<string, unknown> = {},
): Promise<void> {
return this._runBulkItems(
[...this._objBulkSelected],
(id) => ({ type, entry_id: id }),
(id) => ({ type, entry_id: id, ...extra }),
doneMsg,
() => {
this._objBulkSelected = new Set();
@@ -2296,6 +2303,40 @@ export class MaintenanceSupporterPanel extends LitElement {
await this._runObjBulk("maintenance_supporter/object/delete", (k) => t("bulk_objects_deleted", this._lang).replace("{n}", String(k)));
}
/** D#199: one Home Assistant area for every selected object (after a big
* template import, the step that otherwise takes one dialog per object). */
private async _objBulkArea(): Promise<void> {
const ids = [...this._objBulkSelected];
if (ids.length === 0) return;
const areas = Object.values(this.hass?.areas || {}).sort((a, b) => a.name.localeCompare(b.name));
const dlg = this.shadowRoot!.querySelector<MaintenanceConfirmDialog>("maintenance-confirm-dialog");
const result = await dlg?.prompt({
title: t("area", this._lang),
message: t("bulk_n_selected", this._lang).replace("{n}", String(ids.length)),
confirmText: t("save", this._lang),
inputLabel: t("area", this._lang),
inputValue: "",
options: [{ value: "", label: t("no_area", this._lang) }, ...areas.map((a) => ({ value: a.area_id, label: a.name }))],
});
if (!result?.confirmed) return;
const before = new Map(ids.map((id) => [id, this._getObject(id)?.object.area_id ?? null]));
void this._runObjBulk(
"maintenance_supporter/object/update",
(n) => t("bulk_objects_area", this._lang).replace("{n}", String(n)),
async (done) => {
for (const id of done) {
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/object/update", entry_id: id, area_id: before.get(id) ?? null,
});
} catch { /* best effort */ }
}
await this._loadData();
},
{ area_id: result.value || null },
);
}
private _objBulkArchive(): void {
void this._runObjBulk(
"maintenance_supporter/object/archive",
@@ -2322,6 +2363,9 @@ export class MaintenanceSupporterPanel extends LitElement {
</label>
<span class="bulk-count">${t("bulk_n_selected", L).replace("{n}", String(n))}</span>
<span class="bulk-actions">
<ha-button appearance="plain" class="obj-bulk-area" .disabled=${n === 0 || this._actionLoading} @click=${() => void this._objBulkArea()}>
<ha-icon icon="mdi:floor-plan"></ha-icon> ${t("area", L)}…
</ha-button>
<ha-button appearance="plain" class="obj-bulk-archive" .disabled=${n === 0 || this._actionLoading} @click=${() => this._objBulkArchive()}>
<ha-icon icon="mdi:archive-outline"></ha-icon> ${t("archive", L)}
</ha-button>
@@ -2360,6 +2404,90 @@ export class MaintenanceSupporterPanel extends LitElement {
);
}
/** D#199: one change — assignment, labels or settings — to every selected
* task. tasks/update_many writes and reloads each object once (looping
* task/update reloaded an object once per task) and answers with the
* replaced values, which become the undo. */
private async _bulkEdit(mode: BulkMode, rows: TaskRow[]): Promise<void> {
const selected = rows.filter((r) => this._bulkSelected.has(this._bulkKey(r)));
if (selected.length === 0) return;
const dlg = await this._ui<MaintenanceBulkEditDialog>("maintenance-bulk-edit-dialog");
const users = this._userService ? await this._userService.getUsers() : [];
const changes = await dlg?.open(mode, selected, users);
if (!changes) return;
await this._sendBulkUpdate(selected.map((r) => ({ entry_id: r.entry_id, task_id: r.task_id })), changes);
}
private async _sendBulkUpdate(
items: Array<{ entry_id: string; task_id: string; changes?: BulkChanges }>,
changes: BulkChanges | null,
): Promise<void> {
const res = await runWs<{
updated: unknown[];
failed: Array<{ entry_id: string; task_id: string; code: string }>;
previous: Array<{ entry_id: string; task_id: string; changes: BulkChanges }>;
}>(this, { type: "maintenance_supporter/tasks/update_many", items, ...(changes ? { changes } : {}) }, {
busy: (b) => { this._actionLoading = b; },
onError: (m) => this._showToast(m, "error"),
});
if (!res) return;
const isUndo = changes === null;
if (!isUndo) {
this._bulkSelected = new Set();
this._bulkMode = false;
}
await this._loadData();
const failed = codedFailures(res.failed, this._lang);
const msg = bulkResultMessage(t("bulk_updated", this._lang).replace("{n}", String(res.updated.length)), failed, this._lang);
if (!isUndo && res.previous.length > 0) {
this._showUndoToast(msg, () => void this._sendBulkUpdate(res.previous, null));
} else {
this._showToast(msg, failed.length > 0 ? "error" : "info");
}
}
/** Pause every selected task that runs (one date for all, optional). */
private async _bulkPause(rows: TaskRow[]): Promise<void> {
const running = rows.filter((r) => this._bulkSelected.has(this._bulkKey(r)) && r.status !== "paused");
if (running.length === 0) return;
const dlg = this.shadowRoot!.querySelector<MaintenanceConfirmDialog>("maintenance-confirm-dialog");
const result = await dlg?.prompt({
title: t("pause_task", this._lang),
message: t("pause_task_prompt", this._lang),
confirmText: t("pause_task", this._lang),
inputLabel: t("pause_until_label", this._lang),
inputType: "date",
});
if (!result?.confirmed) return;
void this._runBulk(
running,
(row) => ({
type: "maintenance_supporter/task/pause", entry_id: row.entry_id, task_id: row.task_id,
...(result.value ? { until: result.value } : {}),
}),
(n) => t("bulk_paused", this._lang).replace("{n}", String(n)),
async (done) => {
for (const k of done) {
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/task/resume", entry_id: k.entry_id, task_id: k.task_id,
});
} catch { /* best effort */ }
}
await this._loadData();
},
);
}
/** Resume every selected paused task — each starts a fresh cycle. */
private _bulkResume(rows: TaskRow[]): void {
void this._runBulk(
rows.filter((r) => r.status === "paused"),
(row) => ({ type: "maintenance_supporter/task/resume", entry_id: row.entry_id, task_id: row.task_id }),
(n) => t("bulk_resumed", this._lang).replace("{n}", String(n)),
);
}
// --- Actions ---
/** Run a mutating WS action: loading state, data reload, and — on failure —
@@ -3085,6 +3213,7 @@ export class MaintenanceSupporterPanel extends LitElement {
.objects=${this._objects}
@group-saved=${this._onDialogEvent}
></maintenance-group-dialog>
<maintenance-bulk-edit-dialog .hass=${this.hass}></maintenance-bulk-edit-dialog>
<maintenance-adopt-problem-sensors-dialog
.hass=${this.hass}
@problem-sensors-adopted=${(e: CustomEvent) => this._onProblemSensorsAdopted(e)}
@@ -3693,6 +3822,11 @@ export class MaintenanceSupporterPanel extends LitElement {
${this._bulkMenuOpen ? html`
<div class="popup-menu" @click=${(e: Event) => e.stopPropagation()}>
<div class="popup-menu-item bulk-move" @click=${() => { this._bulkMenuOpen = false; void this._bulkMove(rows); }}>${t("move_task", L)}</div>
<div class="popup-menu-item bulk-assign" @click=${() => { this._bulkMenuOpen = false; void this._bulkEdit("assign", rows); }}>${t("bulk_assign", L)}…</div>
<div class="popup-menu-item bulk-labels" @click=${() => { this._bulkMenuOpen = false; void this._bulkEdit("labels", rows); }}>${t("labels", L)}…</div>
<div class="popup-menu-item bulk-edit" @click=${() => { this._bulkMenuOpen = false; void this._bulkEdit("edit", rows); }}>${t("edit", L)}…</div>
<div class="popup-menu-item bulk-pause" @click=${() => { this._bulkMenuOpen = false; void this._bulkPause(rows); }}>${t("pause_task", L)}…</div>
<div class="popup-menu-item bulk-resume" @click=${() => { this._bulkMenuOpen = false; this._bulkResume(rows); }}>${t("resume_task", L)}</div>
</div>
` : nothing}
</span>
@@ -9,21 +9,21 @@
"version": "0.1.0",
"dependencies": {
"pdfjs-dist": "^4.10.38",
"playwright": "1.62"
"playwright": "1.63"
},
"devDependencies": {
"@mdi/js": "^7.4.47",
"@open-wc/testing": "^5.0.0",
"@types/mocha": "^10.0.10",
"@types/react": "^19.2.18",
"@types/react": "^19.3.0",
"@web/dev-server-esbuild": "^2.0.0",
"@web/test-runner": "^1.0.0",
"@web/test-runner-commands": "^1.0.1",
"@web/test-runner-playwright": "^1.0.0",
"esbuild": "^0.28.2",
"lit": "^3.3.3",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"typescript": "^7.0.2"
}
},
@@ -1607,9 +1607,9 @@
"license": "MIT"
},
"node_modules/@types/react": {
"version": "19.2.18",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
"integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3273,6 +3273,7 @@
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -4441,27 +4442,24 @@
}
},
"node_modules/playwright": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.62.1"
"playwright-core": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
@@ -4620,9 +4618,9 @@
}
},
"node_modules/react": {
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz",
"integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==",
"dev": true,
"license": "MIT",
"engines": {
@@ -4630,16 +4628,16 @@
}
},
"node_modules/react-dom": {
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
"integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz",
"integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"scheduler": "^0.27.0"
"scheduler": "^0.28.0"
},
"peerDependencies": {
"react": "^19.2.8"
"react": "^19.3.0"
}
},
"node_modules/readdirp": {
@@ -4873,9 +4871,9 @@
"license": "MIT"
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
"integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==",
"dev": true,
"license": "MIT"
},
@@ -13,20 +13,20 @@
"@mdi/js": "^7.4.47",
"@open-wc/testing": "^5.0.0",
"@types/mocha": "^10.0.10",
"@types/react": "^19.2.18",
"@types/react": "^19.3.0",
"@web/dev-server-esbuild": "^2.0.0",
"@web/test-runner": "^1.0.0",
"@web/test-runner-commands": "^1.0.1",
"@web/test-runner-playwright": "^1.0.0",
"esbuild": "^0.28.2",
"lit": "^3.3.3",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"typescript": "^7.0.2"
},
"dependencies": {
"pdfjs-dist": "^4.10.38",
"playwright": "1.62"
"playwright": "1.63"
},
"overrides": {
"esbuild": "$esbuild",
@@ -55,11 +55,16 @@ export const panelStyles = css`
color: var(--app-header-text-color, white);
}
.breadcrumbs { display: flex; align-items: center; gap: 4px; }
.breadcrumbs a { color: inherit; opacity: 0.8; cursor: pointer; text-decoration: none; }
/* One line, never wider than the header: the path broke only at its
spaces, so on a phone "Maintenance / HVAC System / Filter Replacement"
pushed the search button off screen. The way up shrinks first; the
current page keeps its name as long as it can. */
.breadcrumbs { display: flex; align-items: center; gap: 4px; min-width: 0; }
.breadcrumbs a, .breadcrumbs .current { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.breadcrumbs a { flex: 0 10 auto; color: inherit; opacity: 0.8; cursor: pointer; text-decoration: none; }
.breadcrumbs a:hover { opacity: 1; text-decoration: underline; }
.breadcrumbs .sep { opacity: 0.5; margin: 0 4px; }
.breadcrumbs .current { font-weight: 500; }
.breadcrumbs .sep { flex: none; opacity: 0.5; margin: 0 4px; }
.breadcrumbs .current { flex: 0 1 auto; font-weight: 500; }
.content { flex: 1; overflow-y: auto; padding: 0 16px 16px; }
@@ -742,11 +747,15 @@ export const panelStyles = css`
flex-wrap: wrap;
}
/* Name, object and up to six badges: on a phone they wrap — one row drew
a postponed, assigned, muted task 505 px wide on a 360 px screen. */
.task-header-title {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
}
.task-name-breadcrumb,
@@ -754,6 +763,7 @@ export const panelStyles = css`
cursor: pointer;
color: var(--primary-text-color);
text-decoration: none;
overflow-wrap: anywhere;
}
.task-name-breadcrumb:hover,
@@ -994,13 +1004,19 @@ export const panelStyles = css`
.area-chart { margin: 4px 0 8px; }
.area-chart-max { font-size: 11px; color: var(--secondary-text-color); padding-bottom: 2px; }
/* Hairline gridline at the maximum (labelled above) and the baseline;
bars <= 24px with a 2px gap and a rounded data end (dataviz specs). */
bars <= 24px with a 2px gap and a rounded data end (dataviz specs).
With a credit month (#200) the baseline is the minimum (labelled
below) and a zero line runs between. */
.area-chart-bars {
position: relative;
display: flex; align-items: flex-end; gap: 2px; height: 140px;
border-top: 1px solid var(--divider-color); border-bottom: 1px solid var(--divider-color);
}
.area-chart-zero { position: absolute; left: 0; right: 0; border-top: 1px solid var(--secondary-text-color); opacity: 0.5; pointer-events: none; }
.area-chart-min { font-size: 11px; color: var(--secondary-text-color); padding-top: 2px; }
.area-bar { flex: 1 1 0; min-width: 0; height: 100%; display: flex; align-items: flex-end; justify-content: center; }
.area-bar-fill { width: 100%; max-width: 24px; min-height: 1px; background: var(--primary-color); border-radius: 4px 4px 0 0; }
.area-bar-fill { position: relative; width: 100%; max-width: 24px; min-height: 1px; background: var(--primary-color); border-radius: 4px 4px 0 0; }
.area-bar-fill.credit { background: var(--success-color, #43a047); border-radius: 0 0 4px 4px; }
.area-bar:hover .area-bar-fill { opacity: 0.75; }
.area-chart-axis { display: flex; gap: 2px; margin-top: 4px; }
.area-bar-label {
@@ -28,7 +28,8 @@ export function renderCostDurationCard(
const completedEntries = task.history.filter((h) => h.type === "completed" && (entrySpend(h) != null || h.duration != null));
if (completedEntries.length < 2) return nothing;
const anyCost = completedEntries.some((h) => (entrySpend(h) ?? 0) > 0);
// A credit (#200) is a cost too: a bar below the zero line.
const anyCost = completedEntries.some((h) => (entrySpend(h) ?? 0) !== 0);
const anyDuration = completedEntries.some((h) => (h.duration ?? 0) > 0);
if (!anyCost && !anyDuration) return nothing;
@@ -67,7 +68,8 @@ function renderHistoryChart(task: MaintenanceTask, lang: string, toggle: "cost"
if (entries.length < 2) return nothing;
const dataCost = entries.some((e) => e.cost > 0);
const dataCost = entries.some((e) => e.cost !== 0);
const anyCredit = entries.some((e) => e.cost < 0);
const dataDuration = entries.some((e) => e.duration > 0);
if (!dataCost && !dataDuration) return nothing;
@@ -93,9 +95,13 @@ function renderHistoryChart(task: MaintenanceTask, lang: string, toggle: "cost"
const withYear = needsYear(tsMin, tsMax);
const toX = (ts: number) => PAD_L + ((ts - t0) / (t1 - t0)) * plotW;
const costAxis = niceTicks(0, Math.max(...entries.map((e) => e.cost)) || 1, 3);
// The cost axis reaches below zero only for a credit (#200).
const costMin = Math.min(0, ...entries.map((e) => e.cost));
const costMax = Math.max(0, ...entries.map((e) => e.cost));
const costAxis = niceTicks(costMin, costMax > costMin ? costMax : costMin + 1, 3);
const durAxis = niceTicks(0, Math.max(...entries.map((e) => e.duration)) || 1, 3);
const costY = (v: number) => PAD_T + (1 - v / (costAxis.niceMax || 1)) * plotH;
const costSpan = costAxis.niceMax - costAxis.niceMin || 1;
const costY = (v: number) => PAD_T + (1 - (v - costAxis.niceMin) / costSpan) * plotH;
const durY = (v: number) => PAD_T + (1 - v / (durAxis.niceMax || 1)) * plotH;
// Bars keep a readable width even when completions crowd together.
@@ -122,12 +128,18 @@ function renderHistoryChart(task: MaintenanceTask, lang: string, toggle: "cost"
return svg`<text x="${W - PAD_R + 6}" y="${px(y + 3.5)}" text-anchor="start" fill="var(--accent-color, #ff9800)" font-size="10.5">${fmtNum(v, lang)}m</text>`;
}) : nothing}
${showCost ? entries.filter((e) => e.cost > 0).map((e) => svg`
<rect x="${px(toX(e.ts) - barW / 2)}" y="${px(costY(e.cost))}" width="${px(barW)}" height="${px(plotB - costY(e.cost))}"
fill="var(--primary-color)" opacity="0.6" rx="2">
<title>${fmtDateTick(e.ts, lang, true)}: ${formatCost(e.cost, currencySymbol, lang)}${e.duration ? ` · ${e.duration}m` : ""}</title>
</rect>
`) : nothing}
${showCost && costAxis.niceMin < 0 ? svg`
<line class="cost-zero" x1="${PAD_L}" y1="${px(costY(0))}" x2="${W - PAD_R}" y2="${px(costY(0))}" stroke="var(--secondary-text-color)" stroke-width="1" opacity="0.7" />
` : nothing}
${showCost ? entries.filter((e) => e.cost !== 0).map((e) => {
const top = costY(Math.max(e.cost, 0));
const credit = e.cost < 0;
return svg`
<rect class="${credit ? "credit-bar" : "cost-bar"}" x="${px(toX(e.ts) - barW / 2)}" y="${px(top)}" width="${px(barW)}" height="${px(costY(Math.min(e.cost, 0)) - top)}"
fill="${credit ? "var(--success-color, #43a047)" : "var(--primary-color)"}" opacity="0.6" rx="2">
<title>${fmtDateTick(e.ts, lang, true)}: ${credit ? `${t("cost_kind_credit", lang)} ` : ""}${formatCost(Math.abs(e.cost), currencySymbol, lang)}${e.duration ? ` · ${e.duration}m` : ""}</title>
</rect>`;
}) : nothing}
${showDuration ? svg`
<polyline points="${entries.map((e) => `${px(toX(e.ts))},${px(durY(e.duration))}`).join(" ")}"
fill="none" stroke="var(--accent-color, #ff9800)" stroke-width="2" stroke-linejoin="round" />
@@ -147,6 +159,7 @@ function renderHistoryChart(task: MaintenanceTask, lang: string, toggle: "cost"
</div>
<div class="chart-legend">
${showCost ? html`<span class="legend-item"><span class="legend-swatch" style="background:var(--primary-color);opacity:0.6"></span>${t("cost", lang)}</span>` : nothing}
${showCost && anyCredit ? html`<span class="legend-item"><span class="legend-swatch" style="background:var(--success-color, #43a047);opacity:0.6"></span>${t("cost_kind_credit", lang)}</span>` : nothing}
${showDuration ? html`<span class="legend-item"><span class="legend-swatch" style="background:var(--accent-color, #ff9800)"></span>${t("duration", lang)}</span>` : nothing}
</div>
`;
@@ -225,7 +225,7 @@ export function renderHistoryEntry(entry: HistoryEntry, ctx: HistoryEntryContext
: nothing}
</div>
<div class="history-date">${formatDateTime(entry.timestamp, L)}</div>
${entry.notes ? html`<div>${historyNoteText(entry.notes, L)}</div>` : nothing}
${entry.notes ? html`<div class="history-notes">${historyNoteText(entry.notes, L)}</div>` : nothing}
${renderHistoryPhotos(ctx.hass, entry)}
${renderHistoryReadings(entry, ctx)}
${entry.cost != null || entry.duration != null || entry.trigger_value != null || partsCost != null
@@ -233,7 +233,9 @@ export function renderHistoryEntry(entry: HistoryEntry, ctx: HistoryEntryContext
${entry.cost != null
? stockPurchase
? html`<span>${t("history_purchase_stock", L).replace("{amount}", formatCost(entry.cost, ctx.currencySymbol, L))}</span>`
: html`<span>${t("cost", L)}: ${formatCost(entry.cost, ctx.currencySymbol, L)}</span>`
: entry.cost < 0
? html`<span class="history-credit">${t("cost_kind_credit", L)}: ${formatCost(-entry.cost, ctx.currencySymbol, L)}</span>`
: html`<span>${t("cost", L)}: ${formatCost(entry.cost, ctx.currencySymbol, L)}</span>`
: nothing}
${partsCost != null
? html`<span title=${t(partsCounted ? "history_parts_counted_hint" : "history_parts_info_hint", L)}>${t(partsCounted ? "history_parts_counted" : "history_parts_info", L).replace("{amount}", formatCost(partsCost, ctx.currencySymbol, L))}</span>`
@@ -352,7 +352,11 @@ function renderTaskMeta(task: MaintenanceTask, ctx: TaskDetailContext) {
/** KPI bar with 7 cards. */
function renderKPIBar(task: MaintenanceTask, ctx: TaskDetailContext) {
const L = ctx.lang;
const avgCost = task.times_performed > 0 ? task.total_cost / task.times_performed : 0;
// Per completion with the credits left out (#200: a sale is no job); the
// old division where a payload predates the field.
const avgCost = task.average_cost !== undefined
? task.average_cost ?? 0
: task.times_performed > 0 ? task.total_cost / task.times_performed : 0;
const daysClass = task.days_until_due !== null && task.days_until_due !== undefined
? (task.days_until_due < 0 ? "overdue" : (task.days_until_due <= task.warning_days ? "warning" : ""))
: "";
@@ -181,9 +181,12 @@ export function formatNumber(n: number, lang?: string, digits?: number | Intl.Nu
/** Amount + currency symbol ("13 €" / "12.50 €"; the symbol is optional) —
* every cost figure in the UI and the printables goes through here. The
* fraction digits come from the global `currency_decimals` setting (default
* 0) unless a caller pins them. */
* 0) unless a caller pins them. A credit (#200) is a negative amount; one
* that rounds away prints "0", never Intl's "-0". */
export function formatCost(amount: number, symbol?: string, lang?: string, digits?: number): string {
const num = formatNumber(amount, lang, digits ?? currencyDecimals());
const d = digits ?? currencyDecimals();
const shown = Math.abs(amount) < 0.5 / Math.pow(10, d) ? 0 : amount;
const num = formatNumber(shown, lang, d);
return symbol ? `${num} ${symbol}` : num;
}
@@ -779,6 +782,10 @@ export const sharedStyles = css`
color: var(--secondary-text-color);
}
/* A note keeps its line breaks — what was done, one line each (#202) —
and a long link wraps instead of widening the card. */
.history-notes { white-space: pre-wrap; overflow-wrap: anywhere; }
.history-details {
display: flex;
gap: 12px;
@@ -786,6 +793,7 @@ export const sharedStyles = css`
color: var(--secondary-text-color);
margin-top: 4px;
}
.history-details .history-credit { color: var(--success-color, #43a047); }
/* History filter chips */
.history-filters {
@@ -321,7 +321,10 @@ export interface MaintenanceTask {
trigger_baseline_value?: number | null;
trigger_entity_state?: string;
times_performed: number;
/** Net of credits (#200): a negative cost is money back. */
total_cost: number;
/** Per completion with the credits left out; null without a completion. */
average_cost?: number | null;
average_duration?: number | null;
// Adaptive scheduling
adaptive_config?: AdaptiveConfig | null;