113 files

This commit is contained in:
Home Assistant Version Control
2026-08-26 07:17:31 +00:00
parent 0533eaca8d
commit 15c0e35c18
113 changed files with 12448 additions and 1673 deletions
+1 -1
View File
@@ -201,7 +201,7 @@
},
{
"id": "4d1a17aa96b74064af713fc5e9334862",
"url": "/hacsfiles/ha-treemap-card/treemap-card.js?hacstag=11141174680152",
"url": "/hacsfiles/ha-treemap-card/treemap-card.js?hacstag=11141174680153",
"type": "module"
},
{
@@ -184,6 +184,26 @@ class TriggerStepsMixin(TriggerConfigMixin):
self._on_cancel = self._show_task_action_menu
return await self.async_step_opt_sensor_select()
def _clear_stale_trigger_runtime(self, old_tc: dict[str, Any], new_tc: dict[str, Any]) -> None:
"""Drop persisted trigger runtime when the trigger fundamentally changed.
Mirrors the WS update path (websocket/tasks_crud.py): the Store
runtime wins over config on restore (#102), so an options-flow edit
that changes type/entities/baseline must clear it or the old
counters/anchors silently survive the edit (bug audit 2026-08-22).
"""
if (
old_tc.get("type") != new_tc.get("type")
or old_tc.get("entity_id") != new_tc.get("entity_id")
or old_tc.get("entity_ids") != new_tc.get("entity_ids")
or old_tc.get("trigger_baseline_value") != new_tc.get("trigger_baseline_value")
):
rd = getattr(self.config_entry, "runtime_data", None)
store = getattr(rd, "store", None) if rd else None
if store is not None:
store.clear_trigger_runtime(self._selected_task_id or "")
store.async_delay_save()
def _save_edited_trigger(self) -> ConfigFlowResult:
"""Save edited trigger configuration to an existing task."""
new_data = dict(self.config_entry.data)
@@ -191,6 +211,10 @@ class TriggerStepsMixin(TriggerConfigMixin):
updated_task = dict(new_tasks.get(self._selected_task_id or "", {}))
if "trigger_config" in self._current_task:
self._clear_stale_trigger_runtime(
updated_task.get("trigger_config") or {},
self._current_task["trigger_config"] or {},
)
updated_task["trigger_config"] = self._current_task["trigger_config"]
if CONF_TASK_SCHEDULE_TYPE in self._current_task:
updated_task["schedule_type"] = self._current_task[CONF_TASK_SCHEDULE_TYPE]
@@ -231,17 +255,20 @@ class TriggerStepsMixin(TriggerConfigMixin):
new_tasks = dict(new_data.get(CONF_TASKS, {}))
updated_task = dict(new_tasks.get(self._selected_task_id or "", {}))
old_tc = updated_task.get("trigger_config") or {}
if remaining:
# Partial removal — keep trigger with remaining entities
updated_tc = dict(updated_task.get("trigger_config", {}))
updated_tc["entity_ids"] = remaining
updated_tc.pop("entity_id", None)
updated_task["trigger_config"] = updated_tc
self._clear_stale_trigger_runtime(old_tc, updated_tc)
else:
# Full removal — remove entire trigger config
updated_task.pop("trigger_config", None)
if updated_task.get("schedule_type") == ScheduleType.SENSOR_BASED:
updated_task["schedule_type"] = ScheduleType.TIME_BASED
self._clear_stale_trigger_runtime(old_tc, {})
new_tasks[self._selected_task_id or ""] = updated_task
new_data[CONF_TASKS] = new_tasks
@@ -106,6 +106,12 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._recently_completed: dict[str, float] = {} # task_id -> monotonic timestamp
# Manual completions only — the double-tap dedup window (journey M1).
self._recent_manual_completions: dict[str, float] = {}
# Backdated completions (explicit completed_at) get their OWN dedup,
# keyed by (task_id, timestamp): a double-submitted backfill wrote two
# identical history entries and consumed parts/budget twice (bug audit
# 2026-08-22). Distinct timestamps stay unguarded on purpose — a user
# backfilling several past days in a row is legitimate.
self._recent_backfills: dict[tuple[str, str], float] = {}
# Trigger entity availability tracking
self._startup_time: float = time.monotonic()
@@ -806,7 +812,18 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# Use cached budget totals (recalculate if stale or missing)
cache: dict[str, Any] | None = self.hass.data.get(DOMAIN, {}).get(BUDGET_CACHE_KEY)
if cache is None or (dt_util.now() - cache["last_updated"]).total_seconds() > 3600:
# Stale when old — OR when the local month/year rolled over since the
# compute: the cached buckets are tied to the month they were computed
# in, and a purely age-based rule fired a false "budget nearly
# exhausted" alert for the NEW month during the first cached hour of
# the 1st (bug audit 2026-08-22).
now_local = dt_util.now()
if (
cache is None
or (now_local - cache["last_updated"]).total_seconds() > 3600
or cache["last_updated"].month != now_local.month
or cache["last_updated"].year != now_local.year
):
self._recalculate_budget_cache()
cache = self.hass.data[DOMAIN][BUDGET_CACHE_KEY]
@@ -994,7 +1011,7 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# — it neither checks nor stamps the guard (a stamped guard would
# swallow a normal completion made right after backfilling, and a
# normal completion's stamp must not swallow the backfill).
if completed_at is None:
if completed_at is None and not auto:
last_manual = self._recent_manual_completions.get(task_id)
if last_manual is not None and time.monotonic() - last_manual < MANUAL_COMPLETION_DEDUP_SECONDS:
_LOGGER.info(
@@ -1009,6 +1026,18 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# part-consume / history entry.
self._recent_manual_completions[task_id] = time.monotonic()
if completed_at is not None:
backfill_key = (task_id, completed_at.isoformat())
last_backfill = self._recent_backfills.get(backfill_key)
if last_backfill is not None and time.monotonic() - last_backfill < MANUAL_COMPLETION_DEDUP_SECONDS:
_LOGGER.info(
"Ignoring duplicate backdated completion of %s @ %s (double submit)",
task_id,
completed_at.isoformat(),
)
return
self._recent_backfills[backfill_key] = time.monotonic()
task = MaintenanceTask.from_dict(merged[task_id])
pre_rotation_responsible = task.responsible_user_id
effective_ts = completed_at if completed_at is not None else dt_util.now()
@@ -1192,15 +1221,20 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]):
for entry in reversed(history):
if entry.get("type") != "completed":
continue
try:
last_ts = dt_util.parse_datetime(entry.get("timestamp", ""))
except (ValueError, TypeError):
last_ts = None
if last_ts is not None and (dt_util.now() - last_ts).total_seconds() < 120:
# parse_persisted_utc, NOT dt_util.parse_datetime: history can
# hold NAIVE timestamps (the history-edit dialog sends
# datetime-local without an offset), and `aware - naive` raised
# TypeError OUTSIDE the old try — the recovery coroutine died and
# the auto-completion was silently never recorded (bug audit
# 2026-08-22). The UTC assumption is harmless for a 120 s guard.
from .helpers.dates import parse_persisted_utc
last_ts = parse_persisted_utc(entry.get("timestamp", ""))
if last_ts is not None and (dt_util.utcnow() - last_ts).total_seconds() < 120:
_LOGGER.debug(
"Skipping auto-complete for %s: completed %.0fs ago",
task_id,
(dt_util.now() - last_ts).total_seconds(),
(dt_util.utcnow() - last_ts).total_seconds(),
)
return
break
@@ -103,10 +103,12 @@ def _inject_per_entity_state(config: dict[str, Any], entity_state: dict[str, Any
elif trigger_type == TriggerType.STATE_CHANGE:
if "change_count" in entity_state:
config["trigger_change_count"] = entity_state["change_count"]
# #136: a hold window that was open when HA went down.
if "pending_since" in entity_state:
# #136: a hold window that was open when HA went down. Truthy-gated,
# not `in`: stores written before set_trigger_runtime became a replace
# (2026-08-22) can carry stale/None pending keys forever.
if entity_state.get("pending_since"):
config["trigger_state_pending_since"] = entity_state["pending_since"]
if "pending_state" in entity_state:
if entity_state.get("pending_state"):
config["trigger_state_pending_state"] = entity_state["pending_state"]
elif trigger_type == TriggerType.THRESHOLD:
tes = entity_state.get("threshold_exceeded_since")
@@ -185,6 +185,17 @@ class RuntimeTrigger(BaseTrigger):
self._on_since_dt = now
self._on_since = now.isoformat()
self.hass.async_create_task(self._persist_runtime())
elif not self._is_on(new_val) and self._on_since_dt is not None:
# Restored anchor but the device APPEARS off (deferred setup
# kept the anchor, then the first real state is OFF). Without
# this, nothing ever cleared it and the 5-min periodic persist
# baked wall-clock time into runtime forever — an idle pump
# "ran" 24 h/day (bug audit 2026-08-22). Mirror the setup
# path: accumulate the ON-until-now gap once, then clear.
self._accumulate_elapsed()
self._on_since_dt = None
self._on_since = None
self.hass.async_create_task(self._persist_runtime())
self._update_evaluation()
return
@@ -239,6 +250,15 @@ class RuntimeTrigger(BaseTrigger):
"Runtime trigger: %s turned ON (tracking started)",
self.entity_id,
)
elif not now_on and self._on_since_dt is not None:
# OFF with a lingering anchor (e.g. unavailable→off right after a
# deferred setup kept the restored anchor: was_on reads the
# unavailable old state as not-on, so neither branch above fired).
# Same stale-anchor hazard as the appearance path — settle it.
self._accumulate_elapsed()
self._on_since_dt = None
self._on_since = None
self.hass.async_create_task(self._persist_runtime())
self._update_evaluation()
@@ -130,6 +130,23 @@ class ThresholdTrigger(BaseTrigger):
@callback
def _timer_fired(_now: datetime) -> None:
"""Handle timer completion."""
# Safety net (mirrors the state_change hold timer): only commit
# while the premise still HOLDS. _threshold_exceeded is cleared
# only by a numeric in-range reading, so a sensor that went
# unavailable right after crossing kept it True and the timer
# activated on a value nobody had observed for the whole window
# (bug audit 2026-08-22). Discard the window entirely — a bare
# return would leave the latch set and evaluate() would swallow
# every future exceeding reading; the next one re-arms fresh.
state = self.hass.states.get(self.entity_id)
live = self._get_numeric_value(state) if state is not None else None
if live is None or not self._value_exceeds_threshold(live):
self._threshold_exceeded = False
self._exceeded_since = None
self._exceeded_since_dt = None
if self.hass.is_running:
self.hass.async_create_task(self._persist_exceeded_since())
return
if self._threshold_exceeded:
_LOGGER.debug(
"Threshold for-timer fired: %s (%d min)",
@@ -48,8 +48,13 @@ describe("budget KPI tiles: spent-only display (#104)", () => {
const tiles = sr(el).querySelectorAll(".budget-tile");
expect(tiles.length).to.equal(2);
expect(sr(el).querySelectorAll(".budget-tile-bar").length).to.equal(1);
expect(tiles[0].textContent).to.contain("9.00 / 150 €");
// 2026-08-24: the spent amount carries the full stat-value typography
// (same size as the other KPI chips); the "/ max" ratio is its own
// small line so it can't overflow the grid cell.
expect(tiles[0].querySelector(".stat-value")!.textContent).to.contain("9.00 €");
expect(tiles[0].querySelector(".budget-tile-max")!.textContent).to.contain("/ 150 €");
expect(tiles[1].textContent).to.contain("429.60 €");
expect(tiles[1].querySelector(".budget-tile-max"), "spent-only tile has no ratio line").to.equal(null);
});
it("tiles live INSIDE the stats strip (#125)", async () => {
@@ -99,6 +99,25 @@ describe("trigger-chart", () => {
const marks = el.shadowRoot!.querySelectorAll('rect[fill="var(--success-color, #4caf50)"]');
expect(marks.length).to.equal(1);
});
it("renders the production-shaped projection with real horizontal extent", async () => {
// sparkline.ts builds the projection as [last sample, last sample + 30d].
// Before the domain fix (2026-08-24) the data-only time domain put that
// start on the right plot edge and the x2 clamp collapsed the dashed
// line to zero width — the degradation projection never rendered.
const el = await mount();
const last = POINTS[POINTS.length - 1];
el.projection = [last, { ts: last.ts + 30 * DAY, val: last.val + 15 }];
await el.updateComplete;
const line = el.shadowRoot!.querySelector('line[stroke-dasharray="4,3"]');
expect(line, "projection line rendered").to.exist;
const x1 = Number(line!.getAttribute("x1"));
const x2 = Number(line!.getAttribute("x2"));
expect(x2 - x1, `projection width (${x1} -> ${x2})`).to.be.greaterThan(50);
// still clamped inside the plot
const svgW = Number(el.shadowRoot!.querySelector("svg")!.getAttribute("width"));
expect(x2).to.be.at.most(svgW);
});
});
describe("chart-utils", () => {
@@ -76,8 +76,11 @@ export class MaintenanceBudgetSectionCard extends LitElement {
this._busy = true;
this._error = "";
try {
const m = parseFloat(this._localMonthly);
const y = parseFloat(this._localYearly);
// An emptied field means "remove this budget" and must SEND 0 (the
// backend's off-state) — omitting the key kept the old value, so a
// budget could never be cleared from this card (bug audit 2026-08-22).
const m = this._localMonthly.trim() === "" ? 0 : parseFloat(this._localMonthly);
const y = this._localYearly.trim() === "" ? 0 : parseFloat(this._localYearly);
const settings: Record<string, number> = {};
if (!isNaN(m) && m >= 0) settings.budget_monthly = m;
if (!isNaN(y) && y >= 0) settings.budget_yearly = y;
@@ -71,7 +71,7 @@ export class MaintenanceObjectQuickActionsDialog extends LitElement {
private _onAddTask(): void {
if (!this._entryId) return;
import("../dialog-mount").then(({ openCreateTaskDialog }) => {
openCreateTaskDialog();
openCreateTaskDialog(this._entryId!);
this.close();
});
}
@@ -16,6 +16,8 @@ import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { sharedStyles, t, STATUS_COLORS, formatDate, formatDateTime, formatInterval, formatRecurrence, langOf } from "../styles";
import { describeWsError } from "../ws-errors";
import { isoDateLocal } from "../helpers/calendar-bucket";
import { partsForCompletion } from "../helpers/shared-parts";
import { renderWeibullSection } from "../renderers/weibull";
import { renderPredictionSection } from "../renderers/prediction";
import { renderRecommendationBars } from "../renderers/recommendation";
@@ -75,7 +77,9 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
this._showReset = false;
this._showAdaptive = false;
this._skipReason = "";
this._resetDate = new Date().toISOString().slice(0, 10);
// Local calendar date — toISOString() is UTC and prefills YESTERDAY for
// users east of UTC before their morning (bug audit 2026-08-22).
this._resetDate = isoDateLocal(new Date());
this._open = true;
await Promise.all([this._loadTask(), this._loadFeatures()]);
}
@@ -146,14 +150,36 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
private _onComplete(): void {
if (!this._entryId || !this._taskId || !this._task) return;
// Reuse the existing rich complete-dialog by mounting it on body
import("../dialog-mount").then(({ openCompleteDialog }) => {
// Reuse the existing rich complete-dialog by mounting it on body.
// Pass EVERYTHING the card's direct path passes — omitting
// required_completion_fields let a mandatory note be skipped, and a
// reading task without type+unit never rendered its value field
// (bug audit 2026-08-22).
import("../dialog-mount").then(async ({ openCompleteDialog }) => {
const task = this._task!;
const isBuy = !!(task as { part_ref?: string }).part_ref;
let parts: Parameters<typeof openCompleteDialog>[0]["parts"] = [];
if (!isBuy) {
try {
const r = await this.hass.connection.sendMessagePromise<{
objects: MaintenanceObjectResponse[];
}>({ type: "maintenance_supporter/objects", compact: true });
parts = partsForCompletion(task, this._entryId!, r.objects || [], this._lang);
} catch {
// Parts stay empty — the dialog still completes without them.
}
}
const ok = openCompleteDialog({
entry_id: this._entryId!,
task_id: this._taskId!,
task_name: this._task!.name,
checklist: this._task!.checklist || [],
adaptive_enabled: !!this._task!.adaptive_config?.enabled,
task_name: task.name,
checklist: task.checklist || [],
adaptive_enabled: !!task.adaptive_config?.enabled,
required_completion_fields: task.required_completion_fields || [],
task_type: task.type || "",
reading_unit: (task as { reading_unit?: string }).reading_unit || "",
parts,
consumes_parts: isBuy ? [] : (task.consumes_parts || []),
});
if (ok) {
this._notifyChanged("complete");
@@ -156,7 +156,14 @@ export class MaintenanceTriggerChart extends LitElement {
}
const tsMin = pts[0].ts;
const tsMax = pts[pts.length - 1].ts;
// The dashed projection extends the TIME domain. sparkline.ts builds it
// as [last sample, last sample + 30 d]; with a data-only domain its
// start sat exactly on the right plot edge and the x2 clamp below
// collapsed the line to zero width — the production degradation
// projection never rendered (found via the design-system previews,
// 2026-08-24).
const projEnd = this.projection && this.projection.length === 2 ? this.projection[1].ts : null;
const tsMax = projEnd != null ? Math.max(pts[pts.length - 1].ts, projEnd) : pts[pts.length - 1].ts;
const tsSpan = tsMax - tsMin || 1;
const withYear = needsYear(tsMin, tsMax);
@@ -167,7 +167,10 @@ export function openEditObjectDialog(
return true;
}
export function openCreateTaskDialog(): boolean {
export function openCreateTaskDialog(
entryId = "",
objects?: Array<{ entry_id: string; object: { name: string } }>,
): boolean {
const dlg = getOrCreate<MaintenanceTaskDialog>(TASK_DIALOG_TAG);
if (!syncHass(dlg)) return false;
const hass = getHass();
@@ -179,13 +182,20 @@ export function openCreateTaskDialog(): boolean {
scheduleTimeEnabled: boolean;
completionActionsEnabled: boolean;
defaultWarningDays: number;
openCreate: (entryId?: string) => void;
openCreate: (
entryId: string,
objects?: Array<{ entry_id: string; object: { name: string } }>,
) => void;
};
dlgFull.checklistsEnabled = settings.features.checklists;
dlgFull.scheduleTimeEnabled = settings.features.schedule_time;
dlgFull.completionActionsEnabled = settings.features.completion_actions;
dlgFull.defaultWarningDays = settings.defaultWarningDays;
dlgFull.openCreate();
// openCreate NEEDS a target: a bare call left _entryId undefined and
// _objectChoices empty — no object picker, and save failed on the
// backend's required entry_id (bug audit 2026-08-22). Callers pass the
// entry (quick-actions) or their object list (card header button).
dlgFull.openCreate(entryId, objects);
})();
return true;
}
@@ -273,6 +283,12 @@ export function openCompleteDialog(args: {
adaptive_enabled?: boolean;
/** Details the task demands before it counts as done (v2.44). */
required_completion_fields?: string[];
/** Reading tasks need type+unit or the value field never renders. */
task_type?: string;
reading_unit?: string;
/** #99/#111: per-completion parts selection incl. shared pools. */
parts?: MaintenanceCompleteDialog["parts"];
consumes_parts?: MaintenanceCompleteDialog["consumesParts"];
}): boolean {
const dlg = getOrCreate<MaintenanceCompleteDialog>(COMPLETE_DIALOG_TAG);
if (!syncHass(dlg)) return false;
@@ -282,6 +298,12 @@ export function openCompleteDialog(args: {
dlg.checklist = args.checklist ?? [];
dlg.adaptiveEnabled = !!args.adaptive_enabled;
dlg.requiredFields = args.required_completion_fields ?? [];
// Always assign — the dialog is a singleton, so an omitted field must not
// leak the previous task's value.
dlg.taskType = args.task_type ?? "";
dlg.readingUnit = args.reading_unit ?? "";
dlg.parts = args.parts ?? [];
dlg.consumesParts = args.consumes_parts ?? [];
dlg.lang = (getHass()?.language) || "en";
dlg.open();
return true;
@@ -878,5 +878,16 @@
"cal_editor_default_user": "Výchozí filtr uživatele",
"cal_editor_my_tasks": "Moje úkoly (aktuální uživatel)",
"cal_editor_show_object_filter": "Zobrazit filtr objektu",
"cal_editor_object_hint": "Předvyberte objekt přes YAML: object_filter: \"<název>\" — nebo seznam názvů pro omezení karty na více objektů."
"cal_editor_object_hint": "Předvyberte objekt přes YAML: object_filter: \"<název>\" — nebo seznam názvů pro omezení karty na více objektů.",
"object_history_section": "Historie (všechny úkoly)",
"object_history_all_tasks": "Všechny úkoly",
"object_history_empty": "V tomto období nejsou žádné záznamy.",
"object_history_cap_note": "Historie uchovává až 500 záznamů na úkol — velmi staré záznamy mohou chybět.",
"service_record_title": "Servisní knížka",
"service_record_print": "Servisní knížka (PDF)",
"date": "Datum",
"service_record_entries": "záznamů",
"completed_by": "Dokončil",
"date_from": "Od",
"date_to": "Do"
}
@@ -878,5 +878,16 @@
"cal_editor_default_user": "Standard brugerfilter",
"cal_editor_my_tasks": "Mine opgaver (aktuel bruger)",
"cal_editor_show_object_filter": "Vis objektfilter",
"cal_editor_object_hint": "Forvælg et objekt via YAML: object_filter: \"<navn>\" — eller en liste af navne for at begrænse kortet til flere objekter."
"cal_editor_object_hint": "Forvælg et objekt via YAML: object_filter: \"<navn>\" — eller en liste af navne for at begrænse kortet til flere objekter.",
"object_history_section": "Historik (alle opgaver)",
"object_history_all_tasks": "Alle opgaver",
"object_history_empty": "Ingen poster i denne periode.",
"object_history_cap_note": "Historikken gemmer op til 500 poster pr. opgave — meget gamle poster kan mangle.",
"service_record_title": "Servicebog",
"service_record_print": "Servicebog (PDF)",
"date": "Dato",
"service_record_entries": "poster",
"completed_by": "Udført af",
"date_from": "Fra",
"date_to": "Til"
}
@@ -878,5 +878,16 @@
"cal_editor_default_user": "Standard-Benutzerfilter",
"cal_editor_my_tasks": "Meine Aufgaben (aktueller Benutzer)",
"cal_editor_show_object_filter": "Objektfilter-Dropdown anzeigen",
"cal_editor_object_hint": "Ein Objekt per YAML vorauswählen: object_filter: \"<Objektname>\" — oder eine Namensliste, um die Karte auf mehrere Objekte zu beschränken."
"cal_editor_object_hint": "Ein Objekt per YAML vorauswählen: object_filter: \"<Objektname>\" — oder eine Namensliste, um die Karte auf mehrere Objekte zu beschränken.",
"object_history_section": "Verlauf (alle Aufgaben)",
"object_history_all_tasks": "Alle Aufgaben",
"object_history_empty": "Keine Einträge in diesem Zeitraum.",
"object_history_cap_note": "Der Verlauf umfasst bis zu 500 Einträge pro Aufgabe — sehr alte Einträge können fehlen.",
"service_record_title": "Serviceheft",
"service_record_print": "Serviceheft (PDF)",
"date": "Datum",
"service_record_entries": "Einträge",
"completed_by": "Erledigt von",
"date_from": "Von",
"date_to": "Bis"
}
@@ -878,5 +878,16 @@
"cal_editor_default_user": "Default user filter",
"cal_editor_my_tasks": "My tasks (current user)",
"cal_editor_show_object_filter": "Show object filter dropdown",
"cal_editor_object_hint": "Pre-select one object via YAML: object_filter: \"<object name>\" — or a list of names to restrict the card to several objects."
"cal_editor_object_hint": "Pre-select one object via YAML: object_filter: \"<object name>\" — or a list of names to restrict the card to several objects.",
"object_history_section": "History (all tasks)",
"object_history_all_tasks": "All tasks",
"object_history_empty": "No entries in this range.",
"object_history_cap_note": "History keeps up to 500 entries per task — very old entries may be missing.",
"service_record_title": "Service record",
"service_record_print": "Service record (PDF)",
"date": "Date",
"service_record_entries": "entries",
"completed_by": "Completed by",
"date_from": "From",
"date_to": "To"
}
@@ -878,5 +878,16 @@
"cal_editor_default_user": "Filtro de usuario predeterminado",
"cal_editor_my_tasks": "Mis tareas (usuario actual)",
"cal_editor_show_object_filter": "Mostrar filtro de objeto",
"cal_editor_object_hint": "Preselecciona un objeto por YAML: object_filter: \"<nombre>\" — o una lista de nombres para limitar la tarjeta a varios objetos."
"cal_editor_object_hint": "Preselecciona un objeto por YAML: object_filter: \"<nombre>\" — o una lista de nombres para limitar la tarjeta a varios objetos.",
"object_history_section": "Historial (todas las tareas)",
"object_history_all_tasks": "Todas las tareas",
"object_history_empty": "No hay entradas en este periodo.",
"object_history_cap_note": "El historial conserva hasta 500 entradas por tarea; las entradas muy antiguas pueden faltar.",
"service_record_title": "Registro de mantenimiento",
"service_record_print": "Registro de mantenimiento (PDF)",
"date": "Fecha",
"service_record_entries": "entradas",
"completed_by": "Realizado por",
"date_from": "Desde",
"date_to": "Hasta"
}
@@ -878,5 +878,16 @@
"cal_editor_default_user": "Oletuskäyttäjäsuodatin",
"cal_editor_my_tasks": "Omat tehtävät (nykyinen käyttäjä)",
"cal_editor_show_object_filter": "Näytä kohdesuodatin",
"cal_editor_object_hint": "Esivalitse kohde YAML:lla: object_filter: \"<nimi>\" — tai nimilista rajataksesi kortin useisiin kohteisiin."
"cal_editor_object_hint": "Esivalitse kohde YAML:lla: object_filter: \"<nimi>\" — tai nimilista rajataksesi kortin useisiin kohteisiin.",
"object_history_section": "Historia (kaikki tehtävät)",
"object_history_all_tasks": "Kaikki tehtävät",
"object_history_empty": "Ei merkintöjä tällä aikavälillä.",
"object_history_cap_note": "Historia säilyttää enintään 500 merkintää tehtävää kohden — hyvin vanhat merkinnät voivat puuttua.",
"service_record_title": "Huoltokirja",
"service_record_print": "Huoltokirja (PDF)",
"date": "Päivämäärä",
"service_record_entries": "merkintää",
"completed_by": "Suorittanut",
"date_from": "Alkaen",
"date_to": "Asti"
}
@@ -878,5 +878,16 @@
"cal_editor_default_user": "Filtre utilisateur par défaut",
"cal_editor_my_tasks": "Mes tâches (utilisateur actuel)",
"cal_editor_show_object_filter": "Afficher le filtre d'objet",
"cal_editor_object_hint": "Présélectionnez un objet via YAML : object_filter : \"<nom>\" — ou une liste de noms pour limiter la carte à plusieurs objets."
"cal_editor_object_hint": "Présélectionnez un objet via YAML : object_filter : \"<nom>\" — ou une liste de noms pour limiter la carte à plusieurs objets.",
"object_history_section": "Historique (toutes les tâches)",
"object_history_all_tasks": "Toutes les tâches",
"object_history_empty": "Aucune entrée sur cette période.",
"object_history_cap_note": "L'historique conserve jusqu'à 500 entrées par tâche — les entrées très anciennes peuvent manquer.",
"service_record_title": "Carnet d'entretien",
"service_record_print": "Carnet d'entretien (PDF)",
"date": "Date",
"service_record_entries": "entrées",
"completed_by": "Réalisé par",
"date_from": "Du",
"date_to": "Au"
}
@@ -878,5 +878,16 @@
"cal_editor_default_user": "डिफ़ॉल्ट उपयोगकर्ता फ़िल्टर",
"cal_editor_my_tasks": "मेरे कार्य (वर्तमान उपयोगकर्ता)",
"cal_editor_show_object_filter": "ऑब्जेक्ट फ़िल्टर दिखाएँ",
"cal_editor_object_hint": "YAML से एक ऑब्जेक्ट पहले से चुनें: object_filter: \"<नाम>\" — या कार्ड को कई ऑब्जेक्ट तक सीमित करने हेतु नामों की सूची।"
"cal_editor_object_hint": "YAML से एक ऑब्जेक्ट पहले से चुनें: object_filter: \"<नाम>\" — या कार्ड को कई ऑब्जेक्ट तक सीमित करने हेतु नामों की सूची।",
"object_history_section": "इतिहास (सभी कार्य)",
"object_history_all_tasks": "सभी कार्य",
"object_history_empty": "इस अवधि में कोई प्रविष्टि नहीं है।",
"object_history_cap_note": "इतिहास प्रति कार्य अधिकतम 500 प्रविष्टियाँ रखता है — बहुत पुरानी प्रविष्टियाँ अनुपस्थित हो सकती हैं।",
"service_record_title": "सेवा रिकॉर्ड",
"service_record_print": "सेवा रिकॉर्ड (PDF)",
"date": "दिनांक",
"service_record_entries": "प्रविष्टियाँ",
"completed_by": "द्वारा पूर्ण",
"date_from": "से",
"date_to": "तक"
}
@@ -878,5 +878,16 @@
"cal_editor_default_user": "Alapértelmezett felhasználószűrő",
"cal_editor_my_tasks": "Saját feladatok (aktuális felhasználó)",
"cal_editor_show_object_filter": "Objektumszűrő megjelenítése",
"cal_editor_object_hint": "Előválasztás YAML-lel: object_filter: \"<név>\" — vagy névlista, hogy a kártya több objektumra korlátozódjon."
"cal_editor_object_hint": "Előválasztás YAML-lel: object_filter: \"<név>\" — vagy névlista, hogy a kártya több objektumra korlátozódjon.",
"object_history_section": "Előzmények (összes feladat)",
"object_history_all_tasks": "Összes feladat",
"object_history_empty": "Nincs bejegyzés ebben az időszakban.",
"object_history_cap_note": "Az előzmények feladatonként legfeljebb 500 bejegyzést őriznek meg — a nagyon régiek hiányozhatnak.",
"service_record_title": "Szervizkönyv",
"service_record_print": "Szervizkönyv (PDF)",
"date": "Dátum",
"service_record_entries": "bejegyzés",
"completed_by": "Elvégezte",
"date_from": "Ettől",
"date_to": "Eddig"
}
@@ -878,5 +878,16 @@
"cal_editor_default_user": "Filtro utente predefinito",
"cal_editor_my_tasks": "Le mie attività (utente attuale)",
"cal_editor_show_object_filter": "Mostra il filtro oggetto",
"cal_editor_object_hint": "Preseleziona un oggetto via YAML: object_filter: \"<nome>\" — o un elenco di nomi per limitare la scheda a più oggetti."
"cal_editor_object_hint": "Preseleziona un oggetto via YAML: object_filter: \"<nome>\" — o un elenco di nomi per limitare la scheda a più oggetti.",
"object_history_section": "Cronologia (tutte le attività)",
"object_history_all_tasks": "Tutte le attività",
"object_history_empty": "Nessuna voce in questo periodo.",
"object_history_cap_note": "La cronologia conserva fino a 500 voci per attività — le voci molto vecchie potrebbero mancare.",
"service_record_title": "Libretto di manutenzione",
"service_record_print": "Libretto di manutenzione (PDF)",
"date": "Data",
"service_record_entries": "voci",
"completed_by": "Completato da",
"date_from": "Dal",
"date_to": "Al"
}
@@ -878,5 +878,16 @@
"cal_editor_default_user": "既定のユーザーフィルター",
"cal_editor_my_tasks": "自分のタスク(現在のユーザー)",
"cal_editor_show_object_filter": "オブジェクトフィルターを表示",
"cal_editor_object_hint": "YAML でオブジェクトを事前選択:object_filter: \"<名前>\" — 複数指定はカードを複数オブジェクトに限定します。"
"cal_editor_object_hint": "YAML でオブジェクトを事前選択:object_filter: \"<名前>\" — 複数指定はカードを複数オブジェクトに限定します。",
"object_history_section": "履歴(全タスク)",
"object_history_all_tasks": "すべてのタスク",
"object_history_empty": "この期間の記録はありません。",
"object_history_cap_note": "履歴はタスクごとに最大500件まで保持されます。非常に古い記録は含まれない場合があります。",
"service_record_title": "整備記録",
"service_record_print": "整備記録(PDF",
"date": "日付",
"service_record_entries": "件",
"completed_by": "実施者",
"date_from": "開始",
"date_to": "終了"
}
@@ -878,5 +878,16 @@
"cal_editor_default_user": "기본 사용자 필터",
"cal_editor_my_tasks": "내 작업 (현재 사용자)",
"cal_editor_show_object_filter": "객체 필터 표시",
"cal_editor_object_hint": "YAML로 객체를 미리 선택: object_filter: \"<이름>\" — 이름 목록으로 카드를 여러 객체로 제한할 수 있습니다."
"cal_editor_object_hint": "YAML로 객체를 미리 선택: object_filter: \"<이름>\" — 이름 목록으로 카드를 여러 객체로 제한할 수 있습니다.",
"object_history_section": "기록(전체 작업)",
"object_history_all_tasks": "모든 작업",
"object_history_empty": "이 기간에 기록이 없습니다.",
"object_history_cap_note": "기록은 작업당 최대 500건까지 보관됩니다. 아주 오래된 기록은 없을 수 있습니다.",
"service_record_title": "정비 기록",
"service_record_print": "정비 기록(PDF)",
"date": "날짜",
"service_record_entries": "건",
"completed_by": "수행자",
"date_from": "시작",
"date_to": "종료"
}
@@ -878,5 +878,16 @@
"cal_editor_default_user": "Standard brukerfilter",
"cal_editor_my_tasks": "Mine oppgaver (gjeldende bruker)",
"cal_editor_show_object_filter": "Vis objektfilter",
"cal_editor_object_hint": "Forhåndsvelg et objekt via YAML: object_filter: \"<navn>\" — eller en liste med navn for å begrense kortet til flere objekter."
"cal_editor_object_hint": "Forhåndsvelg et objekt via YAML: object_filter: \"<navn>\" — eller en liste med navn for å begrense kortet til flere objekter.",
"object_history_section": "Historikk (alle oppgaver)",
"object_history_all_tasks": "Alle oppgaver",
"object_history_empty": "Ingen oppføringer i denne perioden.",
"object_history_cap_note": "Historikken beholder opptil 500 oppføringer per oppgave — svært gamle oppføringer kan mangle.",
"service_record_title": "Servicehefte",
"service_record_print": "Servicehefte (PDF)",
"date": "Dato",
"service_record_entries": "oppføringer",
"completed_by": "Utført av",
"date_from": "Fra",
"date_to": "Til"
}
@@ -878,5 +878,16 @@
"cal_editor_default_user": "Standaard gebruikersfilter",
"cal_editor_my_tasks": "Mijn taken (huidige gebruiker)",
"cal_editor_show_object_filter": "Objectfilter tonen",
"cal_editor_object_hint": "Selecteer een object vooraf via YAML: object_filter: \"<naam>\" — of een lijst met namen om de kaart tot meerdere objecten te beperken."
"cal_editor_object_hint": "Selecteer een object vooraf via YAML: object_filter: \"<naam>\" — of een lijst met namen om de kaart tot meerdere objecten te beperken.",
"object_history_section": "Geschiedenis (alle taken)",
"object_history_all_tasks": "Alle taken",
"object_history_empty": "Geen items in deze periode.",
"object_history_cap_note": "De geschiedenis bewaart maximaal 500 items per taak — zeer oude items kunnen ontbreken.",
"service_record_title": "Onderhoudsboekje",
"service_record_print": "Onderhoudsboekje (PDF)",
"date": "Datum",
"service_record_entries": "items",
"completed_by": "Voltooid door",
"date_from": "Van",
"date_to": "Tot"
}
@@ -878,5 +878,16 @@
"cal_editor_default_user": "Domyślny filtr użytkownika",
"cal_editor_my_tasks": "Moje zadania (bieżący użytkownik)",
"cal_editor_show_object_filter": "Pokaż filtr obiektu",
"cal_editor_object_hint": "Wybierz obiekt w YAML: object_filter: \"<nazwa>\" — lub listę nazw, aby ograniczyć kartę do kilku obiektów."
"cal_editor_object_hint": "Wybierz obiekt w YAML: object_filter: \"<nazwa>\" — lub listę nazw, aby ograniczyć kartę do kilku obiektów.",
"object_history_section": "Historia (wszystkie zadania)",
"object_history_all_tasks": "Wszystkie zadania",
"object_history_empty": "Brak wpisów w tym okresie.",
"object_history_cap_note": "Historia przechowuje do 500 wpisów na zadanie — bardzo stare wpisy mogą brakować.",
"service_record_title": "Książka serwisowa",
"service_record_print": "Książka serwisowa (PDF)",
"date": "Data",
"service_record_entries": "wpisów",
"completed_by": "Wykonane przez",
"date_from": "Od",
"date_to": "Do"
}
@@ -878,5 +878,16 @@
"cal_editor_default_user": "Filtro de usuário padrão",
"cal_editor_my_tasks": "Minhas tarefas (usuário atual)",
"cal_editor_show_object_filter": "Mostrar filtro de objeto",
"cal_editor_object_hint": "Pré-selecione um objeto via YAML: object_filter: \"<nome>\" — ou uma lista de nomes para limitar o cartão a vários objetos."
"cal_editor_object_hint": "Pré-selecione um objeto via YAML: object_filter: \"<nome>\" — ou uma lista de nomes para limitar o cartão a vários objetos.",
"object_history_section": "Histórico (todas as tarefas)",
"object_history_all_tasks": "Todas as tarefas",
"object_history_empty": "Sem registros neste período.",
"object_history_cap_note": "O histórico guarda até 500 registros por tarefa — registros muito antigos podem faltar.",
"service_record_title": "Registro de manutenção",
"service_record_print": "Registro de manutenção (PDF)",
"date": "Data",
"service_record_entries": "registros",
"completed_by": "Concluído por",
"date_from": "De",
"date_to": "Até"
}
@@ -878,5 +878,16 @@
"cal_editor_default_user": "Filtro de utilizador predefinido",
"cal_editor_my_tasks": "As minhas tarefas (utilizador atual)",
"cal_editor_show_object_filter": "Mostrar filtro de objeto",
"cal_editor_object_hint": "Pré-selecione um objeto via YAML: object_filter: \"<nome>\" — ou uma lista de nomes para limitar o cartão a vários objetos."
"cal_editor_object_hint": "Pré-selecione um objeto via YAML: object_filter: \"<nome>\" — ou uma lista de nomes para limitar o cartão a vários objetos.",
"object_history_section": "Histórico (todas as tarefas)",
"object_history_all_tasks": "Todas as tarefas",
"object_history_empty": "Sem registos neste período.",
"object_history_cap_note": "O histórico guarda até 500 registos por tarefa — registos muito antigos podem faltar.",
"service_record_title": "Registo de manutenção",
"service_record_print": "Registo de manutenção (PDF)",
"date": "Data",
"service_record_entries": "registos",
"completed_by": "Concluído por",
"date_from": "De",
"date_to": "Até"
}
@@ -878,5 +878,16 @@
"cal_editor_default_user": "Фильтр пользователя по умолчанию",
"cal_editor_my_tasks": "Мои задачи (текущий пользователь)",
"cal_editor_show_object_filter": "Показывать фильтр объекта",
"cal_editor_object_hint": "Предварительный выбор объекта через YAML: object_filter: \"<имя>\" — или список имён, чтобы ограничить карточку несколькими объектами."
"cal_editor_object_hint": "Предварительный выбор объекта через YAML: object_filter: \"<имя>\" — или список имён, чтобы ограничить карточку несколькими объектами.",
"object_history_section": "История (все задачи)",
"object_history_all_tasks": "Все задачи",
"object_history_empty": "Нет записей за этот период.",
"object_history_cap_note": "История хранит до 500 записей на задачу — очень старые записи могут отсутствовать.",
"service_record_title": "Сервисная книжка",
"service_record_print": "Сервисная книжка (PDF)",
"date": "Дата",
"service_record_entries": "записей",
"completed_by": "Выполнил",
"date_from": "С",
"date_to": "По"
}
@@ -878,5 +878,16 @@
"cal_editor_default_user": "Standardanvändarfilter",
"cal_editor_my_tasks": "Mina uppgifter (aktuell användare)",
"cal_editor_show_object_filter": "Visa objektfilter",
"cal_editor_object_hint": "Förvälj ett objekt via YAML: object_filter: \"<namn>\" — eller en lista med namn för att begränsa kortet till flera objekt."
"cal_editor_object_hint": "Förvälj ett objekt via YAML: object_filter: \"<namn>\" — eller en lista med namn för att begränsa kortet till flera objekt.",
"object_history_section": "Historik (alla uppgifter)",
"object_history_all_tasks": "Alla uppgifter",
"object_history_empty": "Inga poster under denna period.",
"object_history_cap_note": "Historiken sparar upp till 500 poster per uppgift — mycket gamla poster kan saknas.",
"service_record_title": "Servicebok",
"service_record_print": "Servicebok (PDF)",
"date": "Datum",
"service_record_entries": "poster",
"completed_by": "Utförd av",
"date_from": "Från",
"date_to": "Till"
}
@@ -878,5 +878,16 @@
"cal_editor_default_user": "Varsayılan kullanıcı filtresi",
"cal_editor_my_tasks": "Görevlerim (geçerli kullanıcı)",
"cal_editor_show_object_filter": "Nesne filtresini göster",
"cal_editor_object_hint": "YAML ile bir nesne önceden seçin: object_filter: \"<ad>\" — veya kartı birden çok nesneyle sınırlamak için ad listesi."
"cal_editor_object_hint": "YAML ile bir nesne önceden seçin: object_filter: \"<ad>\" — veya kartı birden çok nesneyle sınırlamak için ad listesi.",
"object_history_section": "Geçmiş (tüm görevler)",
"object_history_all_tasks": "Tüm görevler",
"object_history_empty": "Bu aralıkta kayıt yok.",
"object_history_cap_note": "Geçmiş, görev başına en fazla 500 kayıt tutar — çok eski kayıtlar eksik olabilir.",
"service_record_title": "Servis kaydı",
"service_record_print": "Servis kaydı (PDF)",
"date": "Tarih",
"service_record_entries": "kayıt",
"completed_by": "Tamamlayan",
"date_from": "Başlangıç",
"date_to": "Bitiş"
}
@@ -878,5 +878,16 @@
"cal_editor_default_user": "Типовий фільтр користувача",
"cal_editor_my_tasks": "Мої завдання (поточний користувач)",
"cal_editor_show_object_filter": "Показувати фільтр об'єкта",
"cal_editor_object_hint": "Попередній вибір об'єкта через YAML: object_filter: \"<назва>\" — або список назв, щоб обмежити картку кількома об'єктами."
"cal_editor_object_hint": "Попередній вибір об'єкта через YAML: object_filter: \"<назва>\" — або список назв, щоб обмежити картку кількома об'єктами.",
"object_history_section": "Історія (усі завдання)",
"object_history_all_tasks": "Усі завдання",
"object_history_empty": "Немає записів за цей період.",
"object_history_cap_note": "Історія зберігає до 500 записів на завдання — дуже старі записи можуть бути відсутні.",
"service_record_title": "Сервісна книжка",
"service_record_print": "Сервісна книжка (PDF)",
"date": "Дата",
"service_record_entries": "записів",
"completed_by": "Виконав",
"date_from": "Від",
"date_to": "До"
}
@@ -878,5 +878,16 @@
"cal_editor_default_user": "默认用户筛选",
"cal_editor_my_tasks": "我的任务(当前用户)",
"cal_editor_show_object_filter": "显示对象筛选",
"cal_editor_object_hint": "通过 YAML 预选对象:object_filter: \"<对象名>\" — 或名称列表,将卡片限定为多个对象。"
"cal_editor_object_hint": "通过 YAML 预选对象:object_filter: \"<对象名>\" — 或名称列表,将卡片限定为多个对象。",
"object_history_section": "历史(全部任务)",
"object_history_all_tasks": "全部任务",
"object_history_empty": "此时间段内没有记录。",
"object_history_cap_note": "每个任务的历史最多保留 500 条记录,很早的记录可能缺失。",
"service_record_title": "维护记录",
"service_record_print": "维护记录(PDF",
"date": "日期",
"service_record_entries": "条记录",
"completed_by": "完成人",
"date_from": "从",
"date_to": "至"
}
@@ -429,7 +429,7 @@ export class MaintenanceSupporterCard extends LitElement {
<mwc-icon-button
class="hdr-add"
title="${t("add_task", L)}"
@click=${() => openCreateTaskDialog()}
@click=${() => openCreateTaskDialog("", this._objects)}
>
<ha-icon icon="mdi:playlist-plus"></ha-icon>
</mwc-icon-button>
@@ -44,6 +44,7 @@ import { UserService } from "./user-service";
import type { MaintenanceObjectDialog } from "./components/object-dialog";
import "./components/documents-section";
import "./components/parts-section";
import "./components/object-history-section";
import "./components/task-documents";
import type { MaintenanceTaskDialog } from "./components/task-dialog";
import type { MaintenanceCompleteDialog } from "./components/complete-dialog";
@@ -3078,7 +3079,8 @@ export class MaintenanceSupporterPanel extends LitElement {
const color = pct >= 100 ? "var(--error-color, #f44336)" : pct >= b.alert_threshold_pct ? "var(--warning-color, #ff9800)" : "var(--success-color, #4caf50)";
return html`
<div class="stat-item budget-tile" title="${label}: ${spent.toFixed(2)} / ${budget.toFixed(2)} ${cs}">
<span class="stat-value budget-tile-value">${spent.toFixed(2)} / ${budget.toFixed(0)} ${cs}</span>
<span class="stat-value budget-tile-value">${spent.toFixed(2)} ${cs}</span>
<span class="budget-tile-max">/ ${budget.toFixed(0)} ${cs}</span>
<div class="budget-tile-bar"><div style="width:${pct}%; background:${color}"></div></div>
<span class="stat-label">${label}</span>
</div>
@@ -3341,6 +3343,16 @@ export class MaintenanceSupporterPanel extends LitElement {
.currencySymbol=${this._currencySymbol}
@parts-changed=${() => this._loadData()}
></maintenance-parts-section>
<maintenance-object-history-section
.hass=${this.hass}
.entryId=${obj.entry_id}
.object=${o}
.tasks=${obj.tasks}
.currencySymbol=${this._currencySymbol}
.userName=${(id: string) => this._userService?.getUserName(id) ?? null}
@open-task=${(e: CustomEvent<{ taskId: string }>) => this._showTask(obj.entry_id, e.detail.taskId)}
></maintenance-object-history-section>
</div>
`;
}
@@ -151,6 +151,13 @@ function progressSpec(task: MaintenanceTask, unit: string, ctx: SparklineContext
case "counter": {
const target = tc.trigger_target_value;
if (target == null || target <= 0) return null;
if (!tc.trigger_delta_mode) {
// Non-delta counters count from zero since the last reset — the raw
// value IS the progress. Subtracting a baseline here showed a fresh
// cycle as stuck at 0 (progress.ts branches the same way; bug audit
// 2026-08-22).
return { progress: Math.max(0, cur), target, unit, meter: null };
}
const base = counterBaseline(task, rawStatsPoints(task, ctx));
return { progress: Math.max(0, cur - (base?.value ?? cur)), target, unit, meter: cur };
}
@@ -296,6 +303,9 @@ function renderChart(task: MaintenanceTask, unit: string, ctx: SparklineContext)
let forceZero = false;
if (triggerType === "counter" && tc.trigger_target_value != null && points.length) {
// Progress domain: cumulative since the last service, never negative.
// Baseline subtraction is a DELTA-mode concept — a non-delta counter's
// raw value already is the cycle progress (bug audit 2026-08-22).
if (tc.trigger_delta_mode) {
const base = counterBaseline(task, points);
if (base) {
if (base.ts != null) {
@@ -304,6 +314,7 @@ function renderChart(task: MaintenanceTask, unit: string, ctx: SparklineContext)
}
points = points.map((p) => ({ ...p, val: Math.max(0, p.val - base.value) }));
}
}
targetValue = tc.trigger_target_value;
forceZero = true;
} else if (triggerType === "state_change" && tc.trigger_target_changes) {
@@ -954,10 +954,20 @@ export const sharedStyles = css`
}
/* Budget KPI tiles in the stats strip (#125) replaced the full-width
budget-bars row. */
budget-bars row. The spent amount inherits .stat-value's full 24px bold
so the budget tiles read exactly like the other KPI chips (user report
2026-08-24: the old 15px override made them visibly smaller); only the
"/ max" suffix stays secondary. */
.stat-item.budget-tile .budget-tile-value {
font-size: 15px;
padding-top: 5px;
white-space: nowrap;
}
/* The "/ max" ratio is its OWN small line between value and bar inline
it overflowed the ~150px grid cell into the neighbouring tile once the
value took the full 24px. */
.budget-tile-max {
font-size: 11px;
line-height: 1.2;
color: var(--secondary-text-color);
white-space: nowrap;
}
.budget-tile-bar {
@@ -1440,9 +1450,10 @@ export const sharedStyles = css`
.weibull-info-row { flex-direction: column; gap: 8px; }
/* Budget tiles on narrow screens (#125): slightly smaller value so the
"x / y €" pair fits the wrapped grid cell. */
.stat-item.budget-tile .budget-tile-value { font-size: 13px; }
/* Budget tiles on narrow screens (#125): the spent amount keeps the
full chip size (consistency, user report 2026-08-24); the "/ max"
suffix is hidden instead the bar and the title carry the ratio. */
.budget-tile-max { display: none; }
.group-card { min-width: 0; max-width: 100%; }
@@ -16,6 +16,7 @@ import { t } from "./styles";
* the raw key as-is so the user can still tell which input was rejected.
*/
const FIELD_LABEL_KEYS: Record<string, string> = {
entry_id: "object",
name: "name",
task_type: "maintenance_type",
schedule_type: "schedule_type",
@@ -878,5 +878,16 @@
"cal_editor_default_user": "Výchozí filtr uživatele",
"cal_editor_my_tasks": "Moje úkoly (aktuální uživatel)",
"cal_editor_show_object_filter": "Zobrazit filtr objektu",
"cal_editor_object_hint": "Předvyberte objekt přes YAML: object_filter: \"<název>\" — nebo seznam názvů pro omezení karty na více objektů."
"cal_editor_object_hint": "Předvyberte objekt přes YAML: object_filter: \"<název>\" — nebo seznam názvů pro omezení karty na více objektů.",
"object_history_section": "Historie (všechny úkoly)",
"object_history_all_tasks": "Všechny úkoly",
"object_history_empty": "V tomto období nejsou žádné záznamy.",
"object_history_cap_note": "Historie uchovává až 500 záznamů na úkol — velmi staré záznamy mohou chybět.",
"service_record_title": "Servisní knížka",
"service_record_print": "Servisní knížka (PDF)",
"date": "Datum",
"service_record_entries": "záznamů",
"completed_by": "Dokončil",
"date_from": "Od",
"date_to": "Do"
}
@@ -878,5 +878,16 @@
"cal_editor_default_user": "Standard brugerfilter",
"cal_editor_my_tasks": "Mine opgaver (aktuel bruger)",
"cal_editor_show_object_filter": "Vis objektfilter",
"cal_editor_object_hint": "Forvælg et objekt via YAML: object_filter: \"<navn>\" — eller en liste af navne for at begrænse kortet til flere objekter."
"cal_editor_object_hint": "Forvælg et objekt via YAML: object_filter: \"<navn>\" — eller en liste af navne for at begrænse kortet til flere objekter.",
"object_history_section": "Historik (alle opgaver)",
"object_history_all_tasks": "Alle opgaver",
"object_history_empty": "Ingen poster i denne periode.",
"object_history_cap_note": "Historikken gemmer op til 500 poster pr. opgave — meget gamle poster kan mangle.",
"service_record_title": "Servicebog",
"service_record_print": "Servicebog (PDF)",
"date": "Dato",
"service_record_entries": "poster",
"completed_by": "Udført af",
"date_from": "Fra",
"date_to": "Til"
}
@@ -878,5 +878,16 @@
"cal_editor_default_user": "Standard-Benutzerfilter",
"cal_editor_my_tasks": "Meine Aufgaben (aktueller Benutzer)",
"cal_editor_show_object_filter": "Objektfilter-Dropdown anzeigen",
"cal_editor_object_hint": "Ein Objekt per YAML vorauswählen: object_filter: \"<Objektname>\" — oder eine Namensliste, um die Karte auf mehrere Objekte zu beschränken."
"cal_editor_object_hint": "Ein Objekt per YAML vorauswählen: object_filter: \"<Objektname>\" — oder eine Namensliste, um die Karte auf mehrere Objekte zu beschränken.",
"object_history_section": "Verlauf (alle Aufgaben)",
"object_history_all_tasks": "Alle Aufgaben",
"object_history_empty": "Keine Einträge in diesem Zeitraum.",
"object_history_cap_note": "Der Verlauf umfasst bis zu 500 Einträge pro Aufgabe — sehr alte Einträge können fehlen.",
"service_record_title": "Serviceheft",
"service_record_print": "Serviceheft (PDF)",
"date": "Datum",
"service_record_entries": "Einträge",
"completed_by": "Erledigt von",
"date_from": "Von",
"date_to": "Bis"
}
@@ -878,5 +878,16 @@
"cal_editor_default_user": "Default user filter",
"cal_editor_my_tasks": "My tasks (current user)",
"cal_editor_show_object_filter": "Show object filter dropdown",
"cal_editor_object_hint": "Pre-select one object via YAML: object_filter: \"<object name>\" — or a list of names to restrict the card to several objects."
"cal_editor_object_hint": "Pre-select one object via YAML: object_filter: \"<object name>\" — or a list of names to restrict the card to several objects.",
"object_history_section": "History (all tasks)",
"object_history_all_tasks": "All tasks",
"object_history_empty": "No entries in this range.",
"object_history_cap_note": "History keeps up to 500 entries per task — very old entries may be missing.",
"service_record_title": "Service record",
"service_record_print": "Service record (PDF)",
"date": "Date",
"service_record_entries": "entries",
"completed_by": "Completed by",
"date_from": "From",
"date_to": "To"
}
@@ -878,5 +878,16 @@
"cal_editor_default_user": "Filtro de usuario predeterminado",
"cal_editor_my_tasks": "Mis tareas (usuario actual)",
"cal_editor_show_object_filter": "Mostrar filtro de objeto",
"cal_editor_object_hint": "Preselecciona un objeto por YAML: object_filter: \"<nombre>\" — o una lista de nombres para limitar la tarjeta a varios objetos."
"cal_editor_object_hint": "Preselecciona un objeto por YAML: object_filter: \"<nombre>\" — o una lista de nombres para limitar la tarjeta a varios objetos.",
"object_history_section": "Historial (todas las tareas)",
"object_history_all_tasks": "Todas las tareas",
"object_history_empty": "No hay entradas en este periodo.",
"object_history_cap_note": "El historial conserva hasta 500 entradas por tarea; las entradas muy antiguas pueden faltar.",
"service_record_title": "Registro de mantenimiento",
"service_record_print": "Registro de mantenimiento (PDF)",
"date": "Fecha",
"service_record_entries": "entradas",
"completed_by": "Realizado por",
"date_from": "Desde",
"date_to": "Hasta"
}
@@ -878,5 +878,16 @@
"cal_editor_default_user": "Oletuskäyttäjäsuodatin",
"cal_editor_my_tasks": "Omat tehtävät (nykyinen käyttäjä)",
"cal_editor_show_object_filter": "Näytä kohdesuodatin",
"cal_editor_object_hint": "Esivalitse kohde YAML:lla: object_filter: \"<nimi>\" — tai nimilista rajataksesi kortin useisiin kohteisiin."
"cal_editor_object_hint": "Esivalitse kohde YAML:lla: object_filter: \"<nimi>\" — tai nimilista rajataksesi kortin useisiin kohteisiin.",
"object_history_section": "Historia (kaikki tehtävät)",
"object_history_all_tasks": "Kaikki tehtävät",
"object_history_empty": "Ei merkintöjä tällä aikavälillä.",
"object_history_cap_note": "Historia säilyttää enintään 500 merkintää tehtävää kohden — hyvin vanhat merkinnät voivat puuttua.",
"service_record_title": "Huoltokirja",
"service_record_print": "Huoltokirja (PDF)",
"date": "Päivämäärä",
"service_record_entries": "merkintää",
"completed_by": "Suorittanut",
"date_from": "Alkaen",
"date_to": "Asti"
}
@@ -878,5 +878,16 @@
"cal_editor_default_user": "Filtre utilisateur par défaut",
"cal_editor_my_tasks": "Mes tâches (utilisateur actuel)",
"cal_editor_show_object_filter": "Afficher le filtre d'objet",
"cal_editor_object_hint": "Présélectionnez un objet via YAML : object_filter : \"<nom>\" — ou une liste de noms pour limiter la carte à plusieurs objets."
"cal_editor_object_hint": "Présélectionnez un objet via YAML : object_filter : \"<nom>\" — ou une liste de noms pour limiter la carte à plusieurs objets.",
"object_history_section": "Historique (toutes les tâches)",
"object_history_all_tasks": "Toutes les tâches",
"object_history_empty": "Aucune entrée sur cette période.",
"object_history_cap_note": "L'historique conserve jusqu'à 500 entrées par tâche — les entrées très anciennes peuvent manquer.",
"service_record_title": "Carnet d'entretien",
"service_record_print": "Carnet d'entretien (PDF)",
"date": "Date",
"service_record_entries": "entrées",
"completed_by": "Réalisé par",
"date_from": "Du",
"date_to": "Au"
}
@@ -878,5 +878,16 @@
"cal_editor_default_user": "डिफ़ॉल्ट उपयोगकर्ता फ़िल्टर",
"cal_editor_my_tasks": "मेरे कार्य (वर्तमान उपयोगकर्ता)",
"cal_editor_show_object_filter": "ऑब्जेक्ट फ़िल्टर दिखाएँ",
"cal_editor_object_hint": "YAML से एक ऑब्जेक्ट पहले से चुनें: object_filter: \"<नाम>\" — या कार्ड को कई ऑब्जेक्ट तक सीमित करने हेतु नामों की सूची।"
"cal_editor_object_hint": "YAML से एक ऑब्जेक्ट पहले से चुनें: object_filter: \"<नाम>\" — या कार्ड को कई ऑब्जेक्ट तक सीमित करने हेतु नामों की सूची।",
"object_history_section": "इतिहास (सभी कार्य)",
"object_history_all_tasks": "सभी कार्य",
"object_history_empty": "इस अवधि में कोई प्रविष्टि नहीं है।",
"object_history_cap_note": "इतिहास प्रति कार्य अधिकतम 500 प्रविष्टियाँ रखता है — बहुत पुरानी प्रविष्टियाँ अनुपस्थित हो सकती हैं।",
"service_record_title": "सेवा रिकॉर्ड",
"service_record_print": "सेवा रिकॉर्ड (PDF)",
"date": "दिनांक",
"service_record_entries": "प्रविष्टियाँ",
"completed_by": "द्वारा पूर्ण",
"date_from": "से",
"date_to": "तक"
}
@@ -878,5 +878,16 @@
"cal_editor_default_user": "Alapértelmezett felhasználószűrő",
"cal_editor_my_tasks": "Saját feladatok (aktuális felhasználó)",
"cal_editor_show_object_filter": "Objektumszűrő megjelenítése",
"cal_editor_object_hint": "Előválasztás YAML-lel: object_filter: \"<név>\" — vagy névlista, hogy a kártya több objektumra korlátozódjon."
"cal_editor_object_hint": "Előválasztás YAML-lel: object_filter: \"<név>\" — vagy névlista, hogy a kártya több objektumra korlátozódjon.",
"object_history_section": "Előzmények (összes feladat)",
"object_history_all_tasks": "Összes feladat",
"object_history_empty": "Nincs bejegyzés ebben az időszakban.",
"object_history_cap_note": "Az előzmények feladatonként legfeljebb 500 bejegyzést őriznek meg — a nagyon régiek hiányozhatnak.",
"service_record_title": "Szervizkönyv",
"service_record_print": "Szervizkönyv (PDF)",
"date": "Dátum",
"service_record_entries": "bejegyzés",
"completed_by": "Elvégezte",
"date_from": "Ettől",
"date_to": "Eddig"
}
@@ -878,5 +878,16 @@
"cal_editor_default_user": "Filtro utente predefinito",
"cal_editor_my_tasks": "Le mie attività (utente attuale)",
"cal_editor_show_object_filter": "Mostra il filtro oggetto",
"cal_editor_object_hint": "Preseleziona un oggetto via YAML: object_filter: \"<nome>\" — o un elenco di nomi per limitare la scheda a più oggetti."
"cal_editor_object_hint": "Preseleziona un oggetto via YAML: object_filter: \"<nome>\" — o un elenco di nomi per limitare la scheda a più oggetti.",
"object_history_section": "Cronologia (tutte le attività)",
"object_history_all_tasks": "Tutte le attività",
"object_history_empty": "Nessuna voce in questo periodo.",
"object_history_cap_note": "La cronologia conserva fino a 500 voci per attività — le voci molto vecchie potrebbero mancare.",
"service_record_title": "Libretto di manutenzione",
"service_record_print": "Libretto di manutenzione (PDF)",
"date": "Data",
"service_record_entries": "voci",
"completed_by": "Completato da",
"date_from": "Dal",
"date_to": "Al"
}
@@ -878,5 +878,16 @@
"cal_editor_default_user": "既定のユーザーフィルター",
"cal_editor_my_tasks": "自分のタスク(現在のユーザー)",
"cal_editor_show_object_filter": "オブジェクトフィルターを表示",
"cal_editor_object_hint": "YAML でオブジェクトを事前選択:object_filter: \"<名前>\" — 複数指定はカードを複数オブジェクトに限定します。"
"cal_editor_object_hint": "YAML でオブジェクトを事前選択:object_filter: \"<名前>\" — 複数指定はカードを複数オブジェクトに限定します。",
"object_history_section": "履歴(全タスク)",
"object_history_all_tasks": "すべてのタスク",
"object_history_empty": "この期間の記録はありません。",
"object_history_cap_note": "履歴はタスクごとに最大500件まで保持されます。非常に古い記録は含まれない場合があります。",
"service_record_title": "整備記録",
"service_record_print": "整備記録(PDF",
"date": "日付",
"service_record_entries": "件",
"completed_by": "実施者",
"date_from": "開始",
"date_to": "終了"
}
@@ -878,5 +878,16 @@
"cal_editor_default_user": "기본 사용자 필터",
"cal_editor_my_tasks": "내 작업 (현재 사용자)",
"cal_editor_show_object_filter": "객체 필터 표시",
"cal_editor_object_hint": "YAML로 객체를 미리 선택: object_filter: \"<이름>\" — 이름 목록으로 카드를 여러 객체로 제한할 수 있습니다."
"cal_editor_object_hint": "YAML로 객체를 미리 선택: object_filter: \"<이름>\" — 이름 목록으로 카드를 여러 객체로 제한할 수 있습니다.",
"object_history_section": "기록(전체 작업)",
"object_history_all_tasks": "모든 작업",
"object_history_empty": "이 기간에 기록이 없습니다.",
"object_history_cap_note": "기록은 작업당 최대 500건까지 보관됩니다. 아주 오래된 기록은 없을 수 있습니다.",
"service_record_title": "정비 기록",
"service_record_print": "정비 기록(PDF)",
"date": "날짜",
"service_record_entries": "건",
"completed_by": "수행자",
"date_from": "시작",
"date_to": "종료"
}
@@ -878,5 +878,16 @@
"cal_editor_default_user": "Standard brukerfilter",
"cal_editor_my_tasks": "Mine oppgaver (gjeldende bruker)",
"cal_editor_show_object_filter": "Vis objektfilter",
"cal_editor_object_hint": "Forhåndsvelg et objekt via YAML: object_filter: \"<navn>\" — eller en liste med navn for å begrense kortet til flere objekter."
"cal_editor_object_hint": "Forhåndsvelg et objekt via YAML: object_filter: \"<navn>\" — eller en liste med navn for å begrense kortet til flere objekter.",
"object_history_section": "Historikk (alle oppgaver)",
"object_history_all_tasks": "Alle oppgaver",
"object_history_empty": "Ingen oppføringer i denne perioden.",
"object_history_cap_note": "Historikken beholder opptil 500 oppføringer per oppgave — svært gamle oppføringer kan mangle.",
"service_record_title": "Servicehefte",
"service_record_print": "Servicehefte (PDF)",
"date": "Dato",
"service_record_entries": "oppføringer",
"completed_by": "Utført av",
"date_from": "Fra",
"date_to": "Til"
}
@@ -878,5 +878,16 @@
"cal_editor_default_user": "Standaard gebruikersfilter",
"cal_editor_my_tasks": "Mijn taken (huidige gebruiker)",
"cal_editor_show_object_filter": "Objectfilter tonen",
"cal_editor_object_hint": "Selecteer een object vooraf via YAML: object_filter: \"<naam>\" — of een lijst met namen om de kaart tot meerdere objecten te beperken."
"cal_editor_object_hint": "Selecteer een object vooraf via YAML: object_filter: \"<naam>\" — of een lijst met namen om de kaart tot meerdere objecten te beperken.",
"object_history_section": "Geschiedenis (alle taken)",
"object_history_all_tasks": "Alle taken",
"object_history_empty": "Geen items in deze periode.",
"object_history_cap_note": "De geschiedenis bewaart maximaal 500 items per taak — zeer oude items kunnen ontbreken.",
"service_record_title": "Onderhoudsboekje",
"service_record_print": "Onderhoudsboekje (PDF)",
"date": "Datum",
"service_record_entries": "items",
"completed_by": "Voltooid door",
"date_from": "Van",
"date_to": "Tot"
}
@@ -878,5 +878,16 @@
"cal_editor_default_user": "Domyślny filtr użytkownika",
"cal_editor_my_tasks": "Moje zadania (bieżący użytkownik)",
"cal_editor_show_object_filter": "Pokaż filtr obiektu",
"cal_editor_object_hint": "Wybierz obiekt w YAML: object_filter: \"<nazwa>\" — lub listę nazw, aby ograniczyć kartę do kilku obiektów."
"cal_editor_object_hint": "Wybierz obiekt w YAML: object_filter: \"<nazwa>\" — lub listę nazw, aby ograniczyć kartę do kilku obiektów.",
"object_history_section": "Historia (wszystkie zadania)",
"object_history_all_tasks": "Wszystkie zadania",
"object_history_empty": "Brak wpisów w tym okresie.",
"object_history_cap_note": "Historia przechowuje do 500 wpisów na zadanie — bardzo stare wpisy mogą brakować.",
"service_record_title": "Książka serwisowa",
"service_record_print": "Książka serwisowa (PDF)",
"date": "Data",
"service_record_entries": "wpisów",
"completed_by": "Wykonane przez",
"date_from": "Od",
"date_to": "Do"
}
@@ -878,5 +878,16 @@
"cal_editor_default_user": "Filtro de usuário padrão",
"cal_editor_my_tasks": "Minhas tarefas (usuário atual)",
"cal_editor_show_object_filter": "Mostrar filtro de objeto",
"cal_editor_object_hint": "Pré-selecione um objeto via YAML: object_filter: \"<nome>\" — ou uma lista de nomes para limitar o cartão a vários objetos."
"cal_editor_object_hint": "Pré-selecione um objeto via YAML: object_filter: \"<nome>\" — ou uma lista de nomes para limitar o cartão a vários objetos.",
"object_history_section": "Histórico (todas as tarefas)",
"object_history_all_tasks": "Todas as tarefas",
"object_history_empty": "Sem registros neste período.",
"object_history_cap_note": "O histórico guarda até 500 registros por tarefa — registros muito antigos podem faltar.",
"service_record_title": "Registro de manutenção",
"service_record_print": "Registro de manutenção (PDF)",
"date": "Data",
"service_record_entries": "registros",
"completed_by": "Concluído por",
"date_from": "De",
"date_to": "Até"
}
@@ -878,5 +878,16 @@
"cal_editor_default_user": "Filtro de utilizador predefinido",
"cal_editor_my_tasks": "As minhas tarefas (utilizador atual)",
"cal_editor_show_object_filter": "Mostrar filtro de objeto",
"cal_editor_object_hint": "Pré-selecione um objeto via YAML: object_filter: \"<nome>\" — ou uma lista de nomes para limitar o cartão a vários objetos."
"cal_editor_object_hint": "Pré-selecione um objeto via YAML: object_filter: \"<nome>\" — ou uma lista de nomes para limitar o cartão a vários objetos.",
"object_history_section": "Histórico (todas as tarefas)",
"object_history_all_tasks": "Todas as tarefas",
"object_history_empty": "Sem registos neste período.",
"object_history_cap_note": "O histórico guarda até 500 registos por tarefa — registos muito antigos podem faltar.",
"service_record_title": "Registo de manutenção",
"service_record_print": "Registo de manutenção (PDF)",
"date": "Data",
"service_record_entries": "registos",
"completed_by": "Concluído por",
"date_from": "De",
"date_to": "Até"
}
@@ -878,5 +878,16 @@
"cal_editor_default_user": "Фильтр пользователя по умолчанию",
"cal_editor_my_tasks": "Мои задачи (текущий пользователь)",
"cal_editor_show_object_filter": "Показывать фильтр объекта",
"cal_editor_object_hint": "Предварительный выбор объекта через YAML: object_filter: \"<имя>\" — или список имён, чтобы ограничить карточку несколькими объектами."
"cal_editor_object_hint": "Предварительный выбор объекта через YAML: object_filter: \"<имя>\" — или список имён, чтобы ограничить карточку несколькими объектами.",
"object_history_section": "История (все задачи)",
"object_history_all_tasks": "Все задачи",
"object_history_empty": "Нет записей за этот период.",
"object_history_cap_note": "История хранит до 500 записей на задачу — очень старые записи могут отсутствовать.",
"service_record_title": "Сервисная книжка",
"service_record_print": "Сервисная книжка (PDF)",
"date": "Дата",
"service_record_entries": "записей",
"completed_by": "Выполнил",
"date_from": "С",
"date_to": "По"
}
@@ -878,5 +878,16 @@
"cal_editor_default_user": "Standardanvändarfilter",
"cal_editor_my_tasks": "Mina uppgifter (aktuell användare)",
"cal_editor_show_object_filter": "Visa objektfilter",
"cal_editor_object_hint": "Förvälj ett objekt via YAML: object_filter: \"<namn>\" — eller en lista med namn för att begränsa kortet till flera objekt."
"cal_editor_object_hint": "Förvälj ett objekt via YAML: object_filter: \"<namn>\" — eller en lista med namn för att begränsa kortet till flera objekt.",
"object_history_section": "Historik (alla uppgifter)",
"object_history_all_tasks": "Alla uppgifter",
"object_history_empty": "Inga poster under denna period.",
"object_history_cap_note": "Historiken sparar upp till 500 poster per uppgift — mycket gamla poster kan saknas.",
"service_record_title": "Servicebok",
"service_record_print": "Servicebok (PDF)",
"date": "Datum",
"service_record_entries": "poster",
"completed_by": "Utförd av",
"date_from": "Från",
"date_to": "Till"
}
@@ -878,5 +878,16 @@
"cal_editor_default_user": "Varsayılan kullanıcı filtresi",
"cal_editor_my_tasks": "Görevlerim (geçerli kullanıcı)",
"cal_editor_show_object_filter": "Nesne filtresini göster",
"cal_editor_object_hint": "YAML ile bir nesne önceden seçin: object_filter: \"<ad>\" — veya kartı birden çok nesneyle sınırlamak için ad listesi."
"cal_editor_object_hint": "YAML ile bir nesne önceden seçin: object_filter: \"<ad>\" — veya kartı birden çok nesneyle sınırlamak için ad listesi.",
"object_history_section": "Geçmiş (tüm görevler)",
"object_history_all_tasks": "Tüm görevler",
"object_history_empty": "Bu aralıkta kayıt yok.",
"object_history_cap_note": "Geçmiş, görev başına en fazla 500 kayıt tutar — çok eski kayıtlar eksik olabilir.",
"service_record_title": "Servis kaydı",
"service_record_print": "Servis kaydı (PDF)",
"date": "Tarih",
"service_record_entries": "kayıt",
"completed_by": "Tamamlayan",
"date_from": "Başlangıç",
"date_to": "Bitiş"
}
@@ -878,5 +878,16 @@
"cal_editor_default_user": "Типовий фільтр користувача",
"cal_editor_my_tasks": "Мої завдання (поточний користувач)",
"cal_editor_show_object_filter": "Показувати фільтр об'єкта",
"cal_editor_object_hint": "Попередній вибір об'єкта через YAML: object_filter: \"<назва>\" — або список назв, щоб обмежити картку кількома об'єктами."
"cal_editor_object_hint": "Попередній вибір об'єкта через YAML: object_filter: \"<назва>\" — або список назв, щоб обмежити картку кількома об'єктами.",
"object_history_section": "Історія (усі завдання)",
"object_history_all_tasks": "Усі завдання",
"object_history_empty": "Немає записів за цей період.",
"object_history_cap_note": "Історія зберігає до 500 записів на завдання — дуже старі записи можуть бути відсутні.",
"service_record_title": "Сервісна книжка",
"service_record_print": "Сервісна книжка (PDF)",
"date": "Дата",
"service_record_entries": "записів",
"completed_by": "Виконав",
"date_from": "Від",
"date_to": "До"
}
@@ -878,5 +878,16 @@
"cal_editor_default_user": "默认用户筛选",
"cal_editor_my_tasks": "我的任务(当前用户)",
"cal_editor_show_object_filter": "显示对象筛选",
"cal_editor_object_hint": "通过 YAML 预选对象:object_filter: \"<对象名>\" — 或名称列表,将卡片限定为多个对象。"
"cal_editor_object_hint": "通过 YAML 预选对象:object_filter: \"<对象名>\" — 或名称列表,将卡片限定为多个对象。",
"object_history_section": "历史(全部任务)",
"object_history_all_tasks": "全部任务",
"object_history_empty": "此时间段内没有记录。",
"object_history_cap_note": "每个任务的历史最多保留 500 条记录,很早的记录可能缺失。",
"service_record_title": "维护记录",
"service_record_print": "维护记录(PDF",
"date": "日期",
"service_record_entries": "条记录",
"completed_by": "完成人",
"date_from": "从",
"date_to": "至"
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,2 +1,2 @@
/*! maintenance_supporter frontend 2.63.1 */
var S="2.63.1";var l="maintenance-supporter",T=`ll-strategy-dashboard-${l}`,D="hui-maintenance-supporter-strategy-editor",C=`/maintenance_supporter_strategy/maintenance-dashboard-strategy.js?v=${S}`,m=null;function v(){return m||(m=import(C)),m}async function I(){let r=await v();if(!r.MaintenanceDashboardStrategy)throw new Error("[maintenance-supporter] strategy bundle loaded but did not export MaintenanceDashboardStrategy");return r.MaintenanceDashboardStrategy}var p=class extends HTMLElement{static getCreateSuggestions(c){return{title:"Maintenance Supporter",icon:"mdi:wrench-clock"}}static async getConfigElement(){return await v(),document.createElement(D)}static async generate(c,f){return(await I()).generate(c,f)}};function M(){try{customElements.define(T,p)}catch{}}M();var w=window;w.customStrategies=w.customStrategies||[];w.customStrategies.some(r=>r.type===l&&r.strategyType==="dashboard")||w.customStrategies.push({type:l,strategyType:"dashboard",name:"Maintenance Supporter",description:"Auto-generated dashboard. Group views by area, status, floor, or due date \u2014 picked from the strategy editor or YAML.",documentationURL:"https://github.com/iluebbe/maintenance_supporter#dashboard-strategy"});(()=>{let r=window;if(r.__msStrategyHealActive)return;r.__msStrategyHealActive=!0;let c=/^\/(auth|config|developer-tools|profile|hassio|history|logbook|map|media-browser|energy|todo|calendar)\b/,f=/Timeout waiting for strategy element ll-strategy-(dashboard-)?maintenance-supporter/i,g=`custom:${l}`;function R(a){let t=[document.documentElement],n=0;for(;t.length&&n<9e3;){let o=t.pop();if(n++,!o)continue;let e=o;if(e.nodeType===1&&e.tagName&&e.tagName.toLowerCase()===a)return e;e.shadowRoot&&t.push(e.shadowRoot);let i=o.children;if(i)for(let d of Array.from(i))t.push(d)}return null}function k(a){let t=a?.views;if(!Array.isArray(t)||!t.length)return null;let n=window.location.pathname.split("/").filter(Boolean).pop()||"",o=t.find(i=>i?.path===n);if(o)return o;let e=Number(n);return Number.isInteger(e)&&t[e]?t[e]:t[0]}function b(){try{let t=R("ha-panel-lovelace")?.lovelace;if(!t)return!1;let n=o=>o?.type;for(let o of[t.config,t.rawConfig]){if(!o)continue;if(n(o.strategy)===g)return!0;let e=k(o);if(e&&n(e.strategy)===g)return!0}return!1}catch{return!1}}function A(){let a=!1,t=0,n=!1,o=!1,e=[document.documentElement],i=0;for(;e.length&&i<9e3;){let d=e.pop();if(i++,!d)continue;let u=d;if(u.nodeType===1&&u.tagName){let s=u.tagName.toLowerCase();(s==="hui-view"||s==="hui-sections-view")&&(a=!0),(s==="ha-card"||s==="hui-card")&&t++,s==="hui-empty-state-card"&&(o=!0),s==="hui-error-card"&&f.test(u.textContent||"")&&(n=!0)}u.shadowRoot&&e.push(u.shadowRoot);let E=d.children;if(E)for(let s of Array.from(E))e.push(s)}return n?!0:o?!1:a&&t<3&&b()}let N="/maintenance_supporter_strategy_shim.js",y=0,_=0;function L(){let a=Date.now();a-_<5e3||y>=3||(_=a,y+=1,import(`${N}?heal=${a}`).catch(()=>{}).finally(()=>{let t=window.location.pathname+window.location.search;history.pushState(null,"","/lovelace"),window.dispatchEvent(new CustomEvent("location-changed")),window.setTimeout(()=>{history.pushState(null,"",t),window.dispatchEvent(new CustomEvent("location-changed"))},200)}))}function h(){if(c.test(window.location.pathname))return;let a=0,t=Date.now(),n=window.setInterval(()=>{a++;try{if(Date.now()-t<6e3)return;if(c.test(window.location.pathname)){window.clearInterval(n);return}A()?L():window.clearInterval(n),a>=30&&window.clearInterval(n)}catch{window.clearInterval(n)}},500)}try{document.readyState==="loading"?window.addEventListener("DOMContentLoaded",h):h(),window.addEventListener("location-changed",()=>{c.test(window.location.pathname)||h()})}catch{}})();
/*! maintenance_supporter frontend 2.64.0 */
var S="2.64.0";var l="maintenance-supporter",T=`ll-strategy-dashboard-${l}`,D="hui-maintenance-supporter-strategy-editor",C=`/maintenance_supporter_strategy/maintenance-dashboard-strategy.js?v=${S}`,m=null;function v(){return m||(m=import(C)),m}async function I(){let r=await v();if(!r.MaintenanceDashboardStrategy)throw new Error("[maintenance-supporter] strategy bundle loaded but did not export MaintenanceDashboardStrategy");return r.MaintenanceDashboardStrategy}var p=class extends HTMLElement{static getCreateSuggestions(c){return{title:"Maintenance Supporter",icon:"mdi:wrench-clock"}}static async getConfigElement(){return await v(),document.createElement(D)}static async generate(c,f){return(await I()).generate(c,f)}};function M(){try{customElements.define(T,p)}catch{}}M();var w=window;w.customStrategies=w.customStrategies||[];w.customStrategies.some(r=>r.type===l&&r.strategyType==="dashboard")||w.customStrategies.push({type:l,strategyType:"dashboard",name:"Maintenance Supporter",description:"Auto-generated dashboard. Group views by area, status, floor, or due date \u2014 picked from the strategy editor or YAML.",documentationURL:"https://github.com/iluebbe/maintenance_supporter#dashboard-strategy"});(()=>{let r=window;if(r.__msStrategyHealActive)return;r.__msStrategyHealActive=!0;let c=/^\/(auth|config|developer-tools|profile|hassio|history|logbook|map|media-browser|energy|todo|calendar)\b/,f=/Timeout waiting for strategy element ll-strategy-(dashboard-)?maintenance-supporter/i,g=`custom:${l}`;function R(a){let t=[document.documentElement],n=0;for(;t.length&&n<9e3;){let o=t.pop();if(n++,!o)continue;let e=o;if(e.nodeType===1&&e.tagName&&e.tagName.toLowerCase()===a)return e;e.shadowRoot&&t.push(e.shadowRoot);let i=o.children;if(i)for(let d of Array.from(i))t.push(d)}return null}function k(a){let t=a?.views;if(!Array.isArray(t)||!t.length)return null;let n=window.location.pathname.split("/").filter(Boolean).pop()||"",o=t.find(i=>i?.path===n);if(o)return o;let e=Number(n);return Number.isInteger(e)&&t[e]?t[e]:t[0]}function b(){try{let t=R("ha-panel-lovelace")?.lovelace;if(!t)return!1;let n=o=>o?.type;for(let o of[t.config,t.rawConfig]){if(!o)continue;if(n(o.strategy)===g)return!0;let e=k(o);if(e&&n(e.strategy)===g)return!0}return!1}catch{return!1}}function A(){let a=!1,t=0,n=!1,o=!1,e=[document.documentElement],i=0;for(;e.length&&i<9e3;){let d=e.pop();if(i++,!d)continue;let u=d;if(u.nodeType===1&&u.tagName){let s=u.tagName.toLowerCase();(s==="hui-view"||s==="hui-sections-view")&&(a=!0),(s==="ha-card"||s==="hui-card")&&t++,s==="hui-empty-state-card"&&(o=!0),s==="hui-error-card"&&f.test(u.textContent||"")&&(n=!0)}u.shadowRoot&&e.push(u.shadowRoot);let E=d.children;if(E)for(let s of Array.from(E))e.push(s)}return n?!0:o?!1:a&&t<3&&b()}let N="/maintenance_supporter_strategy_shim.js",y=0,_=0;function L(){let a=Date.now();a-_<5e3||y>=3||(_=a,y+=1,import(`${N}?heal=${a}`).catch(()=>{}).finally(()=>{let t=window.location.pathname+window.location.search;history.pushState(null,"","/lovelace"),window.dispatchEvent(new CustomEvent("location-changed")),window.setTimeout(()=>{history.pushState(null,"",t),window.dispatchEvent(new CustomEvent("location-changed"))},200)}))}function h(){if(c.test(window.location.pathname))return;let a=0,t=Date.now(),n=window.setInterval(()=>{a++;try{if(Date.now()-t<6e3)return;if(c.test(window.location.pathname)){window.clearInterval(n);return}A()?L():window.clearInterval(n),a>=30&&window.clearInterval(n)}catch{window.clearInterval(n)}},500)}try{document.readyState==="loading"?window.addEventListener("DOMContentLoaded",h):h(),window.addEventListener("location-changed",()=>{c.test(window.location.pathname)||h()})}catch{}})();
@@ -0,0 +1,250 @@
/*! maintenance_supporter frontend 2.64.0 */
import{a as f}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-YHGXWPDQ.js";import{a as p}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-2AYNLB7B.js";import{a as o,b as _,c as i,f as l,g as h,k as g,l as n,p as t,r as u,t as v}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-UMHJSVEU.js";var r=class extends h{constructor(){super(...arguments);this._open=!1;this._loading=!1;this._adopting=!1;this._error="";this._sensors=[];this._selected=new Set;this._users=[];this._responsible="";this._forMinutes="0";this._localeReady=!1;this._userService=null;this._toggle=s=>{let a=new Set(this._selected);a.has(s)?a.delete(s):a.add(s),this._selected=a};this._toggleAll=()=>{this._selected.size===this._sensors.length?this._selected=new Set:this._selected=new Set(this._sensors.map(s=>s.entity_id))};this._adopt=async()=>{if(!(this._selected.size===0||this._adopting)){this._adopting=!0,this._error="";try{let s=this._sensors.filter(e=>this._selected.has(e.entity_id)).map(e=>({entity_id:e.entity_id,name:e.name,entry_id:e.suggested_entry_id??void 0,object_name:e.suggested_object_name,device_id:e.device_id??void 0,part_id:e.suggested_part_id??void 0,responsible_user_id:this._responsible||void 0,for_minutes:parseInt(this._forMinutes,10)>0?parseInt(this._forMinutes,10):void 0})),a=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/problem_sensors/adopt",selections:s});this.dispatchEvent(new CustomEvent("problem-sensors-adopted",{bubbles:!0,composed:!0,detail:a})),this._open=!1}catch(s){this._error=p(s,this._lang)}finally{this._adopting=!1}}}}get _lang(){return u(this.hass)}updated(s){s.has("hass")&&this.hass&&!this._localeReady&&(this._localeReady=!0,v(this._lang).then(()=>this.requestUpdate()))}async open(){this._open=!0,this._loading=!0,this._error="",this._sensors=[],this._selected=new Set,this._responsible="",this._forMinutes="0";try{this._userService?this._userService.updateHass(this.hass):this._userService=new f(this.hass);let[s,a]=await Promise.all([this.hass.connection.sendMessagePromise({type:"maintenance_supporter/problem_sensors/discover"}),this._userService.getUsers().catch(()=>[])]);this._sensors=s.sensors||[],this._selected=new Set(this._sensors.map(e=>e.entity_id)),this._users=a}catch(s){this._error=p(s,this._lang)}finally{this._loading=!1}}_close(){this._open=!1}render(){if(!this._open)return i``;let s=this._lang,a=this._sensors.length>0&&this._selected.size===this._sensors.length;return i`
<div class="overlay" @click=${this._close}>
<div class="card" @click=${e=>e.stopPropagation()}>
<div class="title">${t("adopt_problem_title",s)}</div>
<div class="hint">${t("adopt_problem_hint",s)}</div>
${this._error?i`<div class="error">${this._error}</div>`:l}
${this._loading?i`<div class="loading">…</div>`:this._sensors.length===0?i`<div class="empty">${t("adopt_problem_none",s)}</div>`:i`
<label class="select-all">
<input
type="checkbox"
.checked=${a}
@change=${this._toggleAll}
/>
<span>${t("selected",s)}: ${this._selected.size} / ${this._sensors.length}</span>
</label>
<div class="list">
${this._sensors.map(e=>{let m=this._selected.has(e.entity_id),d=e.state==="on",c=[e.device_name,e.area_name].filter(Boolean).join(" \xB7 ");return i`
<label class="row">
<input
type="checkbox"
.checked=${m}
@change=${()=>this._toggle(e.entity_id)}
/>
<div class="row-main">
<div class="row-top">
<span class="row-name">${e.name}</span>
<span class="chip ${d?"chip-active":"chip-ok"}">
${d?t("adopt_problem_active",s):t("adopt_problem_ok",s)}
</span>
</div>
${c?i`<div class="row-sub">${c}</div>`:l}
<div class="row-target">
${e.suggested_object_name}${e.suggested_entry_id?l:i` <span class="new-tag">${t("adopt_problem_new_object",s)}</span>`}
</div>
${e.suggested_part_name?i`<div class="row-part">
<ha-icon icon="mdi:package-variant-closed"></ha-icon>
${t("adopt_problem_part",s).replace("{name}",e.suggested_part_name)}
</div>`:l}
</div>
</label>
`})}
</div>
`}
${!this._loading&&this._sensors.length>0?i`
<label class="responsible">
<span>${t("for_at_least_minutes",s)}</span>
<input
class="for-input"
type="number"
min="0"
max="1440"
.value=${this._forMinutes}
@input=${e=>this._forMinutes=e.target.value}
/>
</label>
<div class="for-hint">${t("adopt_for_minutes_hint",s)}</div>
`:l}
${!this._loading&&this._sensors.length>0&&this._users.length>0?i`
<label class="responsible">
<span>${t("adopt_problem_responsible",s)}</span>
<select
.value=${this._responsible}
@change=${e=>{this._responsible=e.target.value}}
>
<option value="" ?selected=${!this._responsible}>${t("no_user_assigned",s)}</option>
${this._users.map(e=>i`<option value=${e.id} ?selected=${e.id===this._responsible}>${e.name}</option>`)}
</select>
</label>
`:l}
<div class="actions">
<ha-button appearance="plain" @click=${this._close}>
${t("cancel",s)}
</ha-button>
<ha-button
@click=${this._adopt}
.disabled=${this._selected.size===0||this._adopting}
>
${t("adopt_problem_adopt",s)}
</ha-button>
</div>
</div>
</div>
`}};r.styles=_`
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.card {
background: var(--card-background-color, #fff);
color: var(--primary-text-color);
border-radius: 12px;
padding: 20px;
display: flex;
flex-direction: column;
gap: 12px;
min-width: min(360px, calc(100vw - 24px));
max-width: 560px;
width: 90vw;
max-height: 80vh;
overflow: hidden;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
}
.title {
font-size: 18px;
font-weight: 500;
}
.hint {
color: var(--secondary-text-color);
font-size: 13px;
}
.error {
color: var(--error-color, #f44336);
font-size: 13px;
}
.loading,
.empty {
color: var(--secondary-text-color);
font-size: 14px;
padding: 12px 0;
}
.select-all {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--secondary-text-color);
cursor: pointer;
}
.select-all input {
cursor: pointer;
}
.list {
display: flex;
flex-direction: column;
gap: 6px;
overflow-y: auto;
max-height: 50vh;
}
.row {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 8px;
border: 1px solid var(--divider-color);
border-radius: 6px;
cursor: pointer;
}
.row input {
margin-top: 2px;
cursor: pointer;
}
.row-main {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
flex: 1;
}
.row-top {
display: flex;
align-items: center;
gap: 8px;
}
.row-name {
font-weight: 500;
font-size: 13px;
}
.row-sub {
color: var(--secondary-text-color);
font-size: 12px;
}
.row-target {
color: var(--secondary-text-color);
font-size: 12px;
}
.row-part {
color: var(--secondary-text-color);
font-size: 12px;
display: flex;
align-items: center;
gap: 4px;
}
.row-part ha-icon {
--mdc-icon-size: 14px;
}
.new-tag {
font-style: italic;
}
.chip {
font-size: 11px;
padding: 1px 8px;
border-radius: 10px;
white-space: nowrap;
}
.chip-active {
background: var(--error-color, #f44336);
color: #fff;
}
.chip-ok {
background: var(--divider-color);
color: var(--secondary-text-color);
}
.responsible {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--secondary-text-color);
flex-wrap: wrap;
}
.for-input {
width: 70px;
background: var(--card-background-color);
color: var(--primary-text-color);
border: 1px solid var(--divider-color);
border-radius: 4px;
padding: 4px 6px;
}
.for-hint {
font-size: 11px;
color: var(--secondary-text-color);
margin: -4px 0 2px;
}
.responsible select {
flex: 1;
min-width: 140px;
padding: 4px 6px;
border-radius: 4px;
border: 1px solid var(--divider-color);
background: var(--card-background-color, #fff);
color: var(--primary-text-color);
font-size: 13px;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 8px;
}
`,o([g({attribute:!1})],r.prototype,"hass",2),o([n()],r.prototype,"_open",2),o([n()],r.prototype,"_loading",2),o([n()],r.prototype,"_adopting",2),o([n()],r.prototype,"_error",2),o([n()],r.prototype,"_sensors",2),o([n()],r.prototype,"_selected",2),o([n()],r.prototype,"_users",2),o([n()],r.prototype,"_responsible",2),o([n()],r.prototype,"_forMinutes",2);customElements.get("maintenance-adopt-problem-sensors-dialog")||customElements.define("maintenance-adopt-problem-sensors-dialog",r);export{r as MaintenanceAdoptProblemSensorsDialog};
@@ -0,0 +1,2 @@
/*! maintenance_supporter frontend 2.64.0 */
import{p as a}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-UMHJSVEU.js";var s={entry_id:"object",name:"name",task_type:"maintenance_type",schedule_type:"schedule_type",interval_days:"interval_days",interval_anchor:"interval_anchor",warning_days:"warning_days",last_performed:"last_performed_optional",notes:"notes_optional",documentation_url:"documentation_url_optional",custom_icon:"custom_icon_optional",nfc_tag_id:"nfc_tag_id_optional",responsible_user_id:"responsible_user",entity_slug:"entity_slug",entity_id:"entity_id",area_id:"area_id_optional",manufacturer:"manufacturer_optional",model:"model_optional",serial_number:"serial_number_optional",installation_date:"installation_date_optional",warranty_expiry:"warranty_expiry_optional",checklist:"checklist_steps_optional",reason:"reason",feedback:"feedback",cost:"cost",duration:"duration",description:"description_optional",group_name:"name",group_description:"description_optional",environmental_entity:"environmental_entity_optional",environmental_attribute:"environmental_attribute_optional",trigger_above:"trigger_above",trigger_below:"trigger_below",trigger_equals:"trigger_equals",trigger_not_equals:"trigger_not_equals",trigger_for_minutes:"trigger_for_minutes"};function c(e,o){let r=s[e];if(!r)return e;let t=a(r,o);return t&&t!==r?t:e}function d(e){let r=e.match(/data\['([^']+)'\]/)?.[1],t;return(t=e.match(/length of value must be at most (\d+)/))?{field:r,rule:"too_long",param:t[1]}:(t=e.match(/length of value must be at least (\d+)/))?{field:r,rule:"too_short",param:t[1]}:(t=e.match(/value must be at most (\S+)/))?{field:r,rule:"value_too_high",param:t[1]}:(t=e.match(/value must be at least (\S+)/))?{field:r,rule:"value_too_low",param:t[1]}:/required key not provided/.test(e)?{field:r,rule:"required"}:(t=e.match(/expected (\w+)/))?{field:r,rule:"wrong_type",param:t[1]}:/value must be one of/.test(e)?{field:r,rule:"invalid_choice"}:/not a valid value/.test(e)?{field:r,rule:"invalid_value"}:{field:r,rule:"unknown"}}function g(e,o,r){if(r=r??a("action_error",o),typeof e=="string")return e;if(typeof e!="object"||e===null)return r;let t=e,_=t.message||t.error?.message||"";if(!_)return r;let i=d(_),l=i.field?c(i.field,o):"",n=u=>a(u,o).replace("{field}",l).replace("{n}",i.param??"");switch(i.rule){case"too_long":return n("err_too_long");case"too_short":return n("err_too_short");case"value_too_high":return n("err_value_too_high");case"value_too_low":return n("err_value_too_low");case"required":return n("err_required");case"wrong_type":return n("err_wrong_type").replace("{type}",i.param??"");case"invalid_choice":return n("err_invalid_choice");case"invalid_value":return n("err_invalid_value");default:return _||r}}export{g as a};
@@ -0,0 +1,213 @@
/*! maintenance_supporter frontend 2.64.0 */
import{a as x}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-32P7DPCM.js";import{a,b as v,c as n,f as p,g as b,k as h,l as c,p as t}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-UMHJSVEU.js";function d(l){return l.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;")}function f(l){return!l.startsWith("data:image/svg+xml,")&&!l.startsWith("data:image/png;base64,")?"":d(l)}function q(l){return l.replace(/[/\\:*?"<>|#%]+/g,"").replace(/\s+/g,"-").toLowerCase().substring(0,100)}var o=class extends b{constructor(){super(...arguments);this.lang="en";this._open=!1;this._loading=!1;this._error="";this._viewResult=null;this._completeResult=null;this._urlMode="companion";this._entryId="";this._taskId=null;this._objectName="";this._taskName="";this._generateSeq=0}openForObject(e,i){this._entryId=e,this._taskId=null,this._objectName=i,this._taskName="",this._urlMode="companion",this._error="",this._viewResult=null,this._completeResult=null,this._open=!0,this._generate()}openForTask(e,i,r,s){this._entryId=e,this._taskId=i,this._objectName=r,this._taskName=s,this._urlMode="companion",this._error="",this._viewResult=null,this._completeResult=null,this._open=!0,this._generate()}async _generate(){let e=++this._generateSeq;this._loading=!0,this._error="",this._viewResult=null,this._completeResult=null;try{let i={type:"maintenance_supporter/qr/generate",entry_id:this._entryId,url_mode:this._urlMode};this._taskId&&(i.task_id=this._taskId);let r=[this.hass.connection.sendMessagePromise({...i,action:"view"})];this._taskId&&r.push(this.hass.connection.sendMessagePromise({...i,action:"complete"}));let s=await Promise.all(r);if(e!==this._generateSeq)return;this._viewResult=s[0],s.length>1&&(this._completeResult=s[1])}catch(i){if(e!==this._generateSeq)return;let r=i?.code,s=i?.message;this._error=r==="no_url"||typeof s=="string"&&s.includes("No Home Assistant URL")?t("qr_error_no_url",this.lang):t("qr_error",this.lang)}finally{e===this._generateSeq&&(this._loading=!1)}}_setUrlMode(e){this._urlMode!==e&&(this._urlMode=e,this._generate())}_print(){if(!this._viewResult)return;let e=this._viewResult,i=e.label.task_name?`${e.label.object_name} \u2014 ${e.label.task_name}`:e.label.object_name,r=[e.label.manufacturer,e.label.model].filter(Boolean).join(" "),s=window.open("","_blank","width=600,height=500");if(!s)return;let g=this.lang||"en",u=d(i),m=d(r),_=!!this._completeResult,w=d(t("qr_action_view",g)),$=d(t("qr_action_complete",g));s.document.write(`<!DOCTYPE html><html><head><meta charset="utf-8">
<meta name="color-scheme" content="light">
<title>${u}</title>
<style>
/* Printable sheet \u2014 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}
.qr-col{display:flex;flex-direction:column;align-items:center;gap:6px}
.qr-col img{width:${_?"200px":"280px"}}
.qr-label{font-size:13px;font-weight:500;color:#333}
.url{font-size:10px;color:#999;word-break:break-all;margin-top:8px;max-width:480px}
</style></head><body>
<h2>${u}</h2>
${m?`<div class="sub">${m}</div>`:""}
<div class="qr-row">
<div class="qr-col">
<img src="${f(this._viewResult.svg_data_uri)}" alt="QR Info" />
<div class="qr-label">${w}</div>
</div>
${_?`<div class="qr-col">
<img src="${f(this._completeResult.svg_data_uri)}" alt="QR Complete" />
<div class="qr-label">${$}</div>
</div>`:""}
</div>
<div class="url">${d(this._viewResult.url)}</div>
<script>setTimeout(()=>window.print(),300)<\/script>
</body></html>`),s.document.close()}_downloadSvg(e,i){let r=decodeURIComponent(e.svg_data_uri.replace("data:image/svg+xml,","")),s=this._taskName?`${this._objectName}-${this._taskName}`:this._objectName;x(r,`qr-${q(s)}-${i}.svg`,"image/svg+xml")}_close(){this._open=!1,this._viewResult=null,this._completeResult=null,this._error="",this._loading=!1}render(){if(!this._open)return n``;let e=this.lang||this.hass?.language||"en",i=this._taskName?`${t("qr_code",e)}: ${this._objectName} \u2014 ${this._taskName}`:`${t("qr_code",e)}: ${this._objectName}`,r=!!this._viewResult;return n`
<ha-dialog open @closed=${this._close}>
<div class="dialog-title">${i}</div>
<div class="content">
${this._loading?n`<div class="loading">${t("qr_generating",e)}</div>`:this._error?n`<div class="error">${this._error}</div>`:r?n`
<div class="qr-pair">
<div class="qr-item">
<img
class="qr-image ${this._completeResult?"small":""}"
src="${this._viewResult.svg_data_uri}"
alt="QR Info"
/>
<div class="qr-item-label">${t("qr_action_view",e)}</div>
<button class="dl-btn"
@click=${()=>this._downloadSvg(this._viewResult,"info")}>
<ha-icon icon="mdi:download"></ha-icon>
${t("qr_download",e)}
</button>
</div>
${this._completeResult?n`
<div class="qr-item">
<img
class="qr-image small"
src="${this._completeResult.svg_data_uri}"
alt="QR Complete"
/>
<div class="qr-item-label">${t("qr_action_complete",e)}</div>
<button class="dl-btn"
@click=${()=>this._downloadSvg(this._completeResult,"complete")}>
<ha-icon icon="mdi:download"></ha-icon>
${t("qr_download",e)}
</button>
</div>
`:p}
</div>
<div class="url-display">${this._viewResult.url}</div>
`:p}
<div class="action-row">
<label>${t("qr_url_mode",e)}</label>
<div class="action-toggle">
<button class="toggle-btn ${this._urlMode==="companion"?"active":""}"
@click=${()=>this._setUrlMode("companion")}>${t("qr_mode_companion",e)}</button>
<button class="toggle-btn ${this._urlMode==="local"?"active":""}"
@click=${()=>this._setUrlMode("local")}>${t("qr_mode_local",e)}</button>
<button class="toggle-btn ${this._urlMode==="server"?"active":""}"
@click=${()=>this._setUrlMode("server")}>${t("qr_mode_server",e)}</button>
</div>
</div>
</div>
<div class="dialog-actions">
<ha-button appearance="plain" @click=${this._close}>
${t("cancel",e)}
</ha-button>
<ha-button
@click=${this._print}
.disabled=${!r}
>
${t("qr_print",e)}
</ha-button>
</div>
</ha-dialog>
`}};o.styles=v`
.dialog-title {
font-size: 18px;
font-weight: 500;
padding-bottom: 12px;
}
.content {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
min-width: 300px;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 16px;
}
.qr-pair {
display: flex;
gap: 20px;
justify-content: center;
width: 100%;
}
.qr-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.qr-image {
width: 240px;
height: 240px;
image-rendering: pixelated;
}
.qr-image.small {
width: 180px;
height: 180px;
}
.qr-item-label {
font-size: 12px;
font-weight: 500;
color: var(--secondary-text-color);
text-align: center;
}
.dl-btn {
display: inline-flex;
align-items: center;
gap: 6px;
background: none;
border: 1px solid var(--divider-color, #e0e0e0);
cursor: pointer;
font-size: 13px;
color: var(--primary-text-color);
padding: 6px 14px;
border-radius: 18px;
transition: background 0.2s, border-color 0.2s;
}
.dl-btn:hover {
background: var(--secondary-background-color, #f5f5f5);
border-color: var(--primary-color);
}
.dl-btn ha-icon {
--mdc-icon-size: 18px;
}
.url-display {
font-size: 11px;
color: var(--secondary-text-color);
word-break: break-all;
text-align: center;
max-width: 400px;
}
.loading {
padding: 40px 0;
color: var(--secondary-text-color);
}
.error {
padding: 20px 0;
color: var(--error-color, #f44336);
}
.action-row {
display: flex;
flex-direction: column;
gap: 6px;
width: 100%;
}
.action-row label {
font-size: 13px;
color: var(--secondary-text-color);
}
.action-toggle {
display: flex;
gap: 4px;
background: var(--divider-color, #e0e0e0);
border-radius: 6px;
padding: 3px;
}
.toggle-btn {
flex: 1;
padding: 8px 12px;
border: none;
background: transparent;
color: var(--primary-text-color);
cursor: pointer;
border-radius: 4px;
font-size: 13px;
transition: all 0.2s;
line-height: 1.3;
}
.toggle-btn:hover {
background: rgba(0, 0, 0, 0.05);
}
.toggle-btn.active {
background: var(--primary-color);
color: var(--text-primary-color, #fff);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
}
`,a([h({attribute:!1})],o.prototype,"hass",2),a([h()],o.prototype,"lang",2),a([c()],o.prototype,"_open",2),a([c()],o.prototype,"_loading",2),a([c()],o.prototype,"_error",2),a([c()],o.prototype,"_viewResult",2),a([c()],o.prototype,"_completeResult",2),a([c()],o.prototype,"_urlMode",2);customElements.get("maintenance-qr-dialog")||customElements.define("maintenance-qr-dialog",o);export{o as a};
@@ -0,0 +1,2 @@
/*! maintenance_supporter frontend 2.64.0 */
function c(o,t,e){let l=new Blob([o],{type:e}),d=URL.createObjectURL(l),n=document.createElement("a");n.href=d,n.download=t,n.target="_blank",n.rel="noopener",n.style.display="none",document.body.appendChild(n),n.dispatchEvent(new MouseEvent("click")),document.body.removeChild(n),setTimeout(()=>URL.revokeObjectURL(d),6e4)}function r(o,t){let e=document.createElement("a");e.href=o,e.download=t,e.target="_blank",e.rel="noopener",e.style.display="none",document.body.appendChild(e),e.dispatchEvent(new MouseEvent("click")),document.body.removeChild(e)}export{c as a,r as b};
@@ -0,0 +1,2 @@
/*! maintenance_supporter frontend 2.64.0 */
import{p as _}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-UMHJSVEU.js";function u(e){return`${e.entry_id??""}\0${e.part_id}`}function l(e,r,s,c){let n=!!e.entry_id&&e.entry_id!==r,o=n?e.entry_id:r,a=s.find(p=>p.entry_id===o),t=(a?.parts||[]).find(p=>p.id===e.part_id)||null,d=n&&a?.object?.name||"",i=t?.name||_("shared_part_unknown",c);return{part:t,foreign:n,ownerName:d,label:d?`${i} (${d})`:i}}function P(e,r,s,c){let{part:n,label:o}=l(e,r,s,c),a=n&&n.stock!==null&&n.stock!==void 0?` (${n.stock}${n.unit?" "+n.unit:""})`:"",t=n?.storage_location?` \u2014 ${n.storage_location}`:"";return`${e.quantity}\xD7 ${o}${a}${t}`}function g(e,r,s,c){let o=(s.find(t=>t.entry_id===r)?.parts||[]).map(t=>({...t})),a=new Set(o.map(t=>u({part_id:t.id})));for(let t of e?.consumes_parts||[]){if(!t.entry_id||t.entry_id===r)continue;let d=u(t);if(a.has(d))continue;a.add(d);let{part:i,ownerName:p}=l(t,r,s,c);o.push({id:t.part_id,name:i?.name||_("shared_part_unknown",c),unit:i?.unit,stock:i?.stock??null,storage_location:i?.storage_location,entry_id:t.entry_id,owner_name:p})}return o}var f=["notes","cost","duration","photo","user"],m={notes:"notes_label",cost:"cost",duration:"duration",photo:"photo_label",user:"user_label"};export{u as a,P as b,g as c,f as d,m as e};
@@ -0,0 +1,54 @@
/*! maintenance_supporter frontend 2.64.0 */
import{a as t,b as a,c as i,f as l,g as p,k as r}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-UMHJSVEU.js";var e=class extends p{constructor(){super(...arguments);this.label="";this.value="";this.placeholder="";this.type="text";this.required=!1;this.disabled=!1}_onInput(n){let o=n.target.value;this.value=o,this.dispatchEvent(new CustomEvent("input",{bubbles:!0,composed:!0,detail:{value:o}}))}render(){return i`
<label class="field">
${this.label?i`<span class="label">${this.label}${this.required?i`<span class="req">*</span>`:l}</span>`:l}
<input
.value=${this.value??""}
.type=${this.type}
?required=${this.required}
?disabled=${this.disabled}
placeholder=${this.placeholder}
step=${this.step??l}
min=${this.min??l}
max=${this.max??l}
pattern=${this.pattern??l}
@input=${this._onInput}
@change=${this._onInput}
/>
${this.helper?i`<span class="helper">${this.helper}</span>`:l}
</label>
`}};e.styles=a`
:host { display: block; }
.field {
display: flex;
flex-direction: column;
gap: 4px;
}
.label {
font-size: 12px;
color: var(--secondary-text-color, #888);
font-weight: 500;
}
.req { color: var(--error-color, #f44336); margin-left: 2px; }
input {
padding: 8px 10px;
font-size: 14px;
background: var(--secondary-background-color, rgba(0,0,0,0.06));
color: var(--primary-text-color);
border: 1px solid var(--divider-color, rgba(255,255,255,0.12));
border-radius: 6px;
font-family: inherit;
width: 100%;
box-sizing: border-box;
outline: none;
}
input:focus {
border-color: var(--primary-color);
}
input:disabled { opacity: 0.5; cursor: not-allowed; }
.helper {
font-size: 11px;
color: var(--secondary-text-color);
font-style: italic;
}
`,t([r()],e.prototype,"label",2),t([r()],e.prototype,"value",2),t([r()],e.prototype,"placeholder",2),t([r()],e.prototype,"type",2),t([r({type:Boolean})],e.prototype,"required",2),t([r({type:Boolean})],e.prototype,"disabled",2),t([r()],e.prototype,"step",2),t([r()],e.prototype,"min",2),t([r()],e.prototype,"max",2),t([r()],e.prototype,"pattern",2),t([r()],e.prototype,"helper",2);customElements.get("ms-textfield")||customElements.define("ms-textfield",e);
@@ -0,0 +1,299 @@
/*! maintenance_supporter frontend 2.64.0 */
import{a as _,e as k}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-DJDTMTMV.js";import{a as g}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-2AYNLB7B.js";import{B as m,a as s,b,c as a,f as d,g as v,k as n,l,p as r}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-UMHJSVEU.js";var i=class extends v{constructor(){super(...arguments);this.entryId="";this.taskId="";this.taskName="";this.lang="en";this.checklist=[];this.adaptiveEnabled=!1;this.taskType="";this.readingUnit="";this.restockDefault=null;this.restockUnitCost=null;this.currencySymbol="";this.parts=[];this.consumesParts=[];this.consumesInfo=[];this.requiredFields=[];this._open=!1;this._notes="";this._cost="";this._duration="";this._loading=!1;this._error="";this._checklistState={};this._feedback="needed";this._photoDocId="";this._photoPreview="";this._photoUploading=!1;this._readingValue="";this._restockQty="";this._completedAt="";this._usedParts={};this.checklistPrefill={}}open(){this._open||(this._open=!0,this._notes="",this._cost="",this._duration="",this._error="",this._checklistState=Object.fromEntries(this.checklist.map((e,t)=>[String(t),!!this.checklistPrefill[e]]).filter(([,e])=>e)),this._feedback="needed",this._photoDocId="",this._photoPreview="",this._photoUploading=!1,this._readingValue="",this._restockQty=this.restockDefault!==null?String(this.restockDefault):"",this._completedAt="",this._usedParts=Object.fromEntries(this.consumesParts.map(e=>[_(e),{...e}])))}_toggleCheck(e){let t=String(e);this._checklistState={...this._checklistState,[t]:!this._checklistState[t]}}_setFeedback(e){this._feedback=e}async _onPhotoInput(e){let t=e.target,o=t.files?.[0];if(t.value="",!!o){this._photoUploading=!0,this._error="";try{let c=new FormData;c.append("entry_id",this.entryId),c.append("tags","photo"),c.append("file",o,o.name);let p=await fetch("/api/maintenance_supporter/document/upload",{method:"POST",headers:{Authorization:`Bearer ${this.hass.auth?.data?.access_token??""}`},body:c});if(!p.ok){this._error=p.status===413?r("doc_too_large",this.lang):r("doc_upload_failed",this.lang);return}let u=await p.json();u.id&&(this._photoDocId=u.id,this._photoPreview=URL.createObjectURL(o))}catch{this._error=r("doc_upload_failed",this.lang)}finally{this._photoUploading=!1}}}_removePhoto(){this._photoPreview&&URL.revokeObjectURL(this._photoPreview),this._photoDocId="",this._photoPreview=""}async _complete(){this._loading=!0,this._error="";try{let e={type:"maintenance_supporter/task/complete",entry_id:this.entryId,task_id:this.taskId};if(this._notes&&(e.notes=this._notes),this._cost){let t=parseFloat(this._cost);!isNaN(t)&&t>=0&&(e.cost=t)}if(this._duration){let t=parseInt(this._duration,10);!isNaN(t)&&t>=0&&(e.duration=t)}if(this.checklist.length>0&&(e.checklist_state=this._checklistState),this.adaptiveEnabled&&(e.feedback=this._feedback),this._photoDocId&&(e.photo_doc_id=this._photoDocId),this._completedAt){if(new Date(this._completedAt).getTime()>Date.now()){this._error=r("completed_at_future_error",this.lang),this._loading=!1;return}e.completed_at=this._completedAt.length===16?`${this._completedAt}:00`:this._completedAt}if(this._readingValue!==""){let t=parseFloat(this._readingValue);isNaN(t)||(e.reading_value=t)}if(this.restockDefault!==null&&this._restockQty!==""){let t=parseFloat(this._restockQty);!isNaN(t)&&t>=1&&(e.restock_quantity=t)}this.parts.length>0&&(e.used_parts=Object.values(this._usedParts).filter(t=>Number.isFinite(t.quantity)&&t.quantity>0).map(t=>t.entry_id?{part_id:t.part_id,quantity:t.quantity,entry_id:t.entry_id}:{part_id:t.part_id,quantity:t.quantity})),await this.hass.connection.sendMessagePromise(e),this._open=!1,this.dispatchEvent(new CustomEvent("task-completed"))}catch(e){this._error=g(e,this.lang,r("save_error",this.lang))}finally{this._loading=!1}}get _missingRequired(){let e={notes:this._notes.trim()!=="",cost:this._cost.trim()!=="",duration:this._duration.trim()!=="",photo:this._photoDocId!=="",user:!!this.hass?.user};return this.requiredFields.filter(t=>!e[t])}_req(e){return this.requiredFields.includes(e)?a`<span class="req-mark" aria-hidden="true">*</span>`:d}_partsCostSuggestion(){if(this.restockDefault!==null){let o=parseFloat(this._restockQty);return this.restockUnitCost==null||!Number.isFinite(o)||o<=0?null:Math.round(this.restockUnitCost*o*100)/100}if(!this.parts.length)return null;let e=0,t=!1;for(let o of Object.values(this._usedParts)){let c=this.parts.find(p=>_({part_id:p.id,entry_id:p.entry_id})===_(o));c?.cost!=null&&(e+=c.cost*(o.quantity||1),t=!0)}return t?Math.round(e*100)/100:null}_renderCostSuggestion(e){if(this._cost.trim()!=="")return d;let t=this._partsCostSuggestion();if(t==null||t<=0)return d;let o=`${t.toFixed(2)}${this.currencySymbol?` ${this.currencySymbol}`:""}`;return a`<button
type="button"
class="cost-suggestion"
@click=${()=>this._cost=t.toFixed(2)}
>${r("cost_from_parts",e).replace("{amount}",o)}</button>`}_close(){this._open=!1}render(){if(!this._open)return a``;let e=this.lang||this.hass?.language||"en";return a`
<ha-dialog open @closed=${this._close}>
<div class="dialog-title">${r("complete_title",e)}${this.taskName}</div>
<div class="content">
${this._error?a`<div class="error">${this._error}</div>`:d}
${this.checklist.length>0?a`
<div class="checklist-section">
<label class="checklist-label">${r("checklist",e)}</label>
${this.checklist.map((t,o)=>a`
<label class="checklist-item" @click=${()=>this._toggleCheck(o)}>
<input type="checkbox" .checked=${!!this._checklistState[String(o)]} />
<span>${t}</span>
</label>
`)}
</div>
`:d}
${this.taskType==="reading"?a`
<label class="field">
<span class="field-label">${r("reading_value_label",e)}${this.readingUnit?` (${this.readingUnit})`:""}</span>
<input type="number" step="any" class="field-input"
.value=${this._readingValue}
@input=${t=>this._readingValue=t.target.value} />
</label>`:d}
${this.parts.length?a`<div class="used-parts">
<span class="field-label">${r("complete_parts_used",e)}</span>
${this.parts.map(t=>{let o=_({part_id:t.id,entry_id:t.entry_id}),c=this._usedParts[o],p=c!==void 0,u=t.entry_id?{part_id:t.id,quantity:1,entry_id:t.entry_id}:{part_id:t.id,quantity:1};return a`<div class="used-part-row">
<label class="used-part-check">
<input type="checkbox" .checked=${p}
@change=${f=>{let h={...this._usedParts};f.target.checked?h[o]=h[o]||u:delete h[o],this._usedParts=h}} />
<span
>${t.name}${t.owner_name?a`<span class="used-part-owner"> (${t.owner_name})</span>`:d}${t.stock!==null&&t.stock!==void 0?` (${t.stock}${t.unit?" "+t.unit:""})`:""}</span
>
</label>
${p?a`<input class="used-part-qty" type="number" min="0.01" max="999" step="0.01"
.value=${String(c.quantity)}
@input=${f=>{let h=parseFloat(f.target.value);this._usedParts={...this._usedParts,[o]:{...u,quantity:Number.isFinite(h)&&h>=.01?h:1}}}} />`:d}
</div>`})}
</div>`:this.consumesInfo.length?a`<div class="consumes-hint">
${this.consumesInfo.map(t=>a`<div>${t}</div>`)}
</div>`:d}
${this.restockDefault!==null?a`
<label class="field">
<span class="field-label">${r("restock_quantity_label",e)}</span>
<input type="number" step="0.01" min="0.01" class="field-input"
.value=${this._restockQty}
@input=${t=>this._restockQty=t.target.value} />
</label>`:d}
<!-- Native <input>s rather than <ha-textfield>: when this dialog
is opened from a Lovelace card via dialog-mount, ha-textfield
isn't yet registered (HA loads it lazily when its own panels
need it) so the elements render with zero height and the user
only sees the title + Cancel/Complete buttons the original
bug report. Native inputs always render. -->
<label class="field">
<span class="field-label">${r("notes_optional",e)}${this._req("notes")}</span>
<input type="text" class="field-input"
.value=${this._notes}
@input=${t=>this._notes=t.target.value} />
</label>
<label class="field">
<span class="field-label">${r("cost_optional",e)}${this._req("cost")}</span>
<input type="number" step="0.01" min="0" class="field-input"
.value=${this._cost}
@input=${t=>this._cost=t.target.value} />
${this._renderCostSuggestion(e)}
</label>
<label class="field">
<span class="field-label">${r("duration_minutes",e)}${this._req("duration")}</span>
<input type="number" step="0.01" min="0" class="field-input"
.value=${this._duration}
@input=${t=>this._duration=t.target.value} />
</label>
<label class="field">
<span class="field-label">${r("completed_at_optional",e)}</span>
<input type="datetime-local" class="field-input"
max=${new Date(Date.now()-new Date().getTimezoneOffset()*6e4).toISOString().slice(0,16)}
.value=${this._completedAt}
@change=${t=>this._completedAt=t.target.value} />
</label>
<div class="field">
<span class="field-label">${r("completion_photo_optional",e)}${this._req("photo")}</span>
${this._photoPreview?a`
<div class="photo-preview">
<img src=${this._photoPreview} alt="" />
<button type="button" class="photo-remove" @click=${this._removePhoto}
title="${r("remove",e)}"></button>
</div>`:a`
<label class="photo-pick">
<ha-icon icon="mdi:camera"></ha-icon>
<span>${this._photoUploading?r("uploading",e):r("add_photo",e)}</span>
<input type="file" accept="image/*" capture="environment"
?disabled=${this._photoUploading}
@change=${this._onPhotoInput} />
</label>`}
</div>
${this.adaptiveEnabled?a`
<div class="feedback-section">
<label class="feedback-label">${r("was_maintenance_needed",e)}</label>
<div class="feedback-buttons">
<button
class="feedback-btn ${this._feedback==="needed"?"selected":""}"
@click=${()=>this._setFeedback("needed")}
>${r("feedback_needed",e)}</button>
<button
class="feedback-btn ${this._feedback==="not_needed"?"selected":""}"
@click=${()=>this._setFeedback("not_needed")}
>${r("feedback_not_needed",e)}</button>
<button
class="feedback-btn ${this._feedback==="not_sure"?"selected":""}"
@click=${()=>this._setFeedback("not_sure")}
>${r("feedback_not_sure",e)}</button>
</div>
</div>
`:d}
</div>
<div class="dialog-actions">
<ha-button appearance="plain" @click=${this._close}>
${r("cancel",e)}
</ha-button>
<ha-button
@click=${this._complete}
.disabled=${this._loading||this._missingRequired.length>0}
title=${this._missingRequired.length?this._missingRequired.map(t=>r("err_required",e).replace("{field}",r(k[t]??t,e))).join(" \xB7 "):""}
>
${this._loading?r("completing",e):r("complete",e)}
</ha-button>
</div>
</ha-dialog>
`}};i.styles=[m,b`
.req-mark {
color: var(--error-color, #f44336);
margin-left: 2px;
font-weight: 600;
}
/* #104: one-click cost suggestion from parts — quiet link-style chip. */
.cost-suggestion {
align-self: flex-start;
margin-top: 4px;
padding: 0;
border: none;
background: none;
color: var(--primary-color);
font-size: 12.5px;
cursor: pointer;
text-decoration: underline dotted;
text-underline-offset: 2px;
}
.dialog-title {
font-size: 18px;
font-weight: 500;
padding-bottom: 12px;
}
.content {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 300px;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 16px;
}
.consumes-hint {
font-size: 13px;
color: var(--secondary-text-color);
border-left: 3px solid var(--primary-color);
padding: 4px 8px;
margin: 4px 0 8px;
}
/* #99: editable per-completion parts selection */
.used-parts { margin: 4px 0 8px; display: flex; flex-direction: column; gap: 4px; }
.used-part-row { display: flex; align-items: center; gap: 8px; }
.used-part-check {
display: flex; align-items: center; gap: 6px; flex: 1;
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);
background: var(--card-background-color);
color: var(--primary-text-color);
}
.error {
color: var(--error-color, #f44336);
font-size: 13px;
}
/* .field/.field-label/.field-input come from nativeFieldStyles */
.photo-pick {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border: 1px dashed var(--divider-color);
border-radius: 8px;
cursor: pointer;
font-size: 13px;
color: var(--secondary-text-color);
width: fit-content;
}
.photo-pick:hover { border-color: var(--primary-color); }
.photo-pick input[type="file"] { display: none; }
.photo-preview {
position: relative;
width: fit-content;
}
.photo-preview img {
max-width: 160px;
max-height: 160px;
border-radius: 8px;
display: block;
}
.photo-remove {
position: absolute;
top: -8px;
right: -8px;
width: 24px;
height: 24px;
border-radius: 50%;
border: none;
background: var(--error-color, #db4437);
color: #fff;
cursor: pointer;
font-size: 12px;
line-height: 1;
}
.checklist-section {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px 0;
border-bottom: 1px solid var(--divider-color);
margin-bottom: 4px;
}
.checklist-label {
font-weight: 500;
font-size: 13px;
color: var(--secondary-text-color);
}
.checklist-item {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 4px 0;
font-size: 14px;
}
.checklist-item input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
}
.feedback-section {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px 0;
border-top: 1px solid var(--divider-color);
}
.feedback-label {
font-weight: 500;
font-size: 13px;
color: var(--secondary-text-color);
}
.feedback-buttons {
display: flex;
gap: 8px;
}
.feedback-btn {
flex: 1;
padding: 8px 12px;
border: 1px solid var(--divider-color);
border-radius: 8px;
background: var(--card-background-color, #fff);
color: var(--primary-text-color);
font-size: 13px;
cursor: pointer;
text-align: center;
transition: all 0.2s;
}
.feedback-btn:hover {
background: var(--secondary-background-color, #f5f5f5);
}
.feedback-btn.selected {
background: var(--primary-color);
color: var(--text-primary-color, #fff);
border-color: var(--primary-color);
}
`],s([n({attribute:!1})],i.prototype,"hass",2),s([n()],i.prototype,"entryId",2),s([n()],i.prototype,"taskId",2),s([n()],i.prototype,"taskName",2),s([n()],i.prototype,"lang",2),s([n({type:Array})],i.prototype,"checklist",2),s([n({type:Boolean})],i.prototype,"adaptiveEnabled",2),s([n()],i.prototype,"taskType",2),s([n()],i.prototype,"readingUnit",2),s([n({attribute:!1})],i.prototype,"restockDefault",2),s([n({attribute:!1})],i.prototype,"restockUnitCost",2),s([n()],i.prototype,"currencySymbol",2),s([n({attribute:!1})],i.prototype,"parts",2),s([n({attribute:!1})],i.prototype,"consumesParts",2),s([n({type:Array})],i.prototype,"consumesInfo",2),s([n({type:Array})],i.prototype,"requiredFields",2),s([l()],i.prototype,"_open",2),s([l()],i.prototype,"_notes",2),s([l()],i.prototype,"_cost",2),s([l()],i.prototype,"_duration",2),s([l()],i.prototype,"_loading",2),s([l()],i.prototype,"_error",2),s([l()],i.prototype,"_checklistState",2),s([l()],i.prototype,"_feedback",2),s([l()],i.prototype,"_photoDocId",2),s([l()],i.prototype,"_photoPreview",2),s([l()],i.prototype,"_photoUploading",2),s([l()],i.prototype,"_readingValue",2),s([l()],i.prototype,"_restockQty",2),s([l()],i.prototype,"_completedAt",2),s([l()],i.prototype,"_usedParts",2),s([n({attribute:!1})],i.prototype,"checklistPrefill",2);customElements.get("maintenance-complete-dialog")||customElements.define("maintenance-complete-dialog",i);export{i as a};
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
/*! maintenance_supporter frontend 2.64.0 */
import{b as i}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-32P7DPCM.js";async function l(e,n,t=300){return(await e.connection.sendMessagePromise({type:"auth/sign_path",path:n,expires:t})).path}async function r(e,n,t=300){return l(e,`/api/maintenance_supporter/document/${n}`,t)}async function y(e,n,t=""){let a=window.open("about:blank","_blank");try{let o=await r(e,n);a&&(a.location.href=new URL(o+t,window.location.origin).href)}catch(o){throw a&&a.close(),o}}async function p(e,n,t){i(await r(e,n,30),t)}function d(e){let n=URL.createObjectURL(new Blob([e],{type:"text/html"}));window.open(n,"_blank"),setTimeout(()=>URL.revokeObjectURL(n),6e4)}var c=[{key:"name",labelKey:"name",required:!0},{key:"manufacturer",labelKey:"manufacturer"},{key:"model",labelKey:"model"},{key:"serial_number",labelKey:"serial_number_label"},{key:"installation_date",labelKey:"installed"},{key:"warranty_expiry",labelKey:"warranty"},{key:"area_id",labelKey:"area"},{key:"documentation_url",labelKey:"documentation_url_label"},{key:"notes",labelKey:"object_notes_label"},{key:"task_count",labelKey:"tasks"},{key:"actions",labelKey:"actions"}],u=c.map(e=>e.key),s=["name","manufacturer","model","serial_number","installation_date","warranty_expiry","area_id","task_count","actions"];function g(e){if(!Array.isArray(e))return[...s];let n=new Set,t=[];for(let a of e)typeof a=="string"&&u.includes(a)&&!n.has(a)&&(n.add(a),t.push(a));return t.length?(t.includes("name")||t.unshift("name"),t):[...s]}export{l as a,r as b,y as c,p as d,d as e,c as f,s as g,g as h};
@@ -0,0 +1,143 @@
/*! maintenance_supporter frontend 2.64.0 */
import{a as u}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-2AYNLB7B.js";import{a as r,b as _,c as l,f as o,g as p,k as d,l as s,p as i,r as h}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-UMHJSVEU.js";var e=class extends p{constructor(){super(...arguments);this.objects=[];this._open=!1;this._loading=!1;this._error="";this._name="";this._manufacturer="";this._model="";this._serialNumber="";this._areaId="";this._installationDate="";this._warrantyExpiry="";this._documentationUrl="";this._notes="";this._haDeviceId="";this._parentEntryId="";this._entryId=null}get _lang(){return h(this.hass)}openCreate(){this._entryId=null,this._name="",this._manufacturer="",this._model="",this._serialNumber="",this._areaId="",this._installationDate="",this._warrantyExpiry="",this._documentationUrl="",this._notes="",this._haDeviceId="",this._parentEntryId="",this._error="",this._open=!0}openEdit(a,n){this._entryId=a,this._name=n.name||"",this._manufacturer=n.manufacturer||"",this._model=n.model||"",this._serialNumber=n.serial_number||"",this._areaId=n.area_id||"",this._installationDate=n.installation_date||"",this._warrantyExpiry=n.warranty_expiry||"",this._documentationUrl=n.documentation_url||"",this._notes=n.notes||"",this._haDeviceId=n.ha_device_id||"",this._parentEntryId=n.parent_entry_id||"",this._error="",this._open=!0}async _save(){if(!this._loading&&this._name.trim()){this._loading=!0,this._error="";try{this._entryId?await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/object/update",entry_id:this._entryId,name:this._name,manufacturer:this._manufacturer||null,model:this._model||null,serial_number:this._serialNumber||null,area_id:this._areaId||null,installation_date:this._installationDate||null,warranty_expiry:this._warrantyExpiry||null,documentation_url:this._documentationUrl.trim()||null,notes:this._notes.trim()||null,ha_device_id:this._haDeviceId||null,parent_entry_id:this._parentEntryId||null}):await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/object/create",name:this._name,manufacturer:this._manufacturer||null,model:this._model||null,serial_number:this._serialNumber||null,area_id:this._areaId||null,installation_date:this._installationDate||null,warranty_expiry:this._warrantyExpiry||null,documentation_url:this._documentationUrl.trim()||null,notes:this._notes.trim()||null,ha_device_id:this._haDeviceId||null,parent_entry_id:this._parentEntryId||null}),this._open=!1,this.dispatchEvent(new CustomEvent("object-saved"))}catch(a){this._error=u(a,this._lang,i("save_error",this._lang))}finally{this._loading=!1}}}_parentChoices(){return(this.objects||[]).filter(a=>a.entry_id!==this._entryId)}_close(){this._open=!1}render(){if(!this._open)return l``;let a=this._lang,n=this._entryId?i("edit_object",a):i("new_object",a);return l`
<ha-dialog open @closed=${this._close}>
<div class="dialog-title">${n}</div>
<div class="content">
${this._error?l`<div class="error">${this._error}</div>`:o}
<ms-textfield
label="${i("name",a)}"
required
.value=${this._name}
@input=${t=>this._name=t.target.value}
></ms-textfield>
<ms-textfield
label="${i("manufacturer_optional",a)}"
.value=${this._manufacturer}
@input=${t=>this._manufacturer=t.target.value}
></ms-textfield>
<ms-textfield
label="${i("model_optional",a)}"
.value=${this._model}
@input=${t=>this._model=t.target.value}
></ms-textfield>
<ms-textfield
label="${i("serial_number_optional",a)}"
.value=${this._serialNumber}
@input=${t=>this._serialNumber=t.target.value}
></ms-textfield>
<ms-textfield
label="${i("documentation_url_optional",a)}"
type="url"
.value=${this._documentationUrl}
@input=${t=>this._documentationUrl=t.target.value}
></ms-textfield>
<ha-area-picker
.hass=${this.hass}
label="${i("area_id_optional",a)}"
.value=${this._areaId}
@value-changed=${t=>this._areaId=t.detail.value||""}
></ha-area-picker>
<ms-textfield
label="${i("installation_date_optional",a)}"
type="date"
.value=${this._installationDate}
@input=${t=>this._installationDate=t.target.value}
></ms-textfield>
<ms-textfield
label="${i("warranty_expiry_optional",a)}"
type="date"
.value=${this._warrantyExpiry}
@input=${t=>this._warrantyExpiry=t.target.value}
></ms-textfield>
<ha-form
.hass=${this.hass}
.data=${{device:this._haDeviceId||void 0}}
.schema=${[{name:"device",selector:{device:{}}}]}
.computeLabel=${()=>i("link_device_optional",a)}
@value-changed=${t=>this._haDeviceId=t.detail.value?.device||""}
></ha-form>
${this._parentChoices().length?l`<label class="textarea-field">
<span class="textarea-label">${i("parent_object_optional",a)}</span>
<select
class="parent-select"
.value=${this._parentEntryId}
@change=${t=>this._parentEntryId=t.target.value}
>
<option value="" ?selected=${!this._parentEntryId}>
${i("parent_none",a)}
</option>
${this._parentChoices().map(t=>l`<option
value=${t.entry_id}
?selected=${this._parentEntryId===t.entry_id}
>${t.object.name}</option>`)}
</select>
</label>`:o}
<label class="textarea-field">
<span class="textarea-label">${i("object_notes_optional",a)}</span>
<textarea
rows="3"
.value=${this._notes}
@input=${t=>this._notes=t.target.value}
></textarea>
</label>
</div>
<div class="dialog-actions">
<ha-button appearance="plain" @click=${this._close}>
${i("cancel",this._lang)}
</ha-button>
<ha-button
@click=${this._save}
.disabled=${this._loading||!this._name.trim()}
>
${this._loading?i("saving",this._lang):i("save",this._lang)}
</ha-button>
</div>
</ha-dialog>
`}};e.styles=_`
.dialog-title {
font-size: 18px;
font-weight: 500;
padding-bottom: 12px;
}
.content {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 300px;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 16px;
}
ms-textfield {
display: block;
}
.textarea-field {
display: flex; flex-direction: column; gap: 4px;
}
.textarea-label {
font-size: 12px; color: var(--secondary-text-color, #888); font-weight: 500;
}
.textarea-field textarea {
padding: 8px 10px; font-size: 14px; font-family: inherit;
background: var(--secondary-background-color, rgba(0,0,0,0.06));
color: var(--primary-text-color);
border: 1px solid var(--divider-color); border-radius: 6px;
resize: vertical;
}
.textarea-field textarea:focus {
outline: none; border-color: var(--primary-color);
}
.parent-select {
padding: 8px 10px; font-size: 14px; font-family: inherit;
background: var(--secondary-background-color, rgba(0,0,0,0.06));
color: var(--primary-text-color);
border: 1px solid var(--divider-color); border-radius: 6px;
}
.error {
color: var(--error-color, #f44336);
font-size: 13px;
}
`,r([d({attribute:!1})],e.prototype,"hass",2),r([d({attribute:!1})],e.prototype,"objects",2),r([s()],e.prototype,"_open",2),r([s()],e.prototype,"_loading",2),r([s()],e.prototype,"_error",2),r([s()],e.prototype,"_name",2),r([s()],e.prototype,"_manufacturer",2),r([s()],e.prototype,"_model",2),r([s()],e.prototype,"_serialNumber",2),r([s()],e.prototype,"_areaId",2),r([s()],e.prototype,"_installationDate",2),r([s()],e.prototype,"_warrantyExpiry",2),r([s()],e.prototype,"_documentationUrl",2),r([s()],e.prototype,"_notes",2),r([s()],e.prototype,"_haDeviceId",2),r([s()],e.prototype,"_parentEntryId",2),r([s()],e.prototype,"_entryId",2);customElements.get("maintenance-object-dialog")||customElements.define("maintenance-object-dialog",e);export{e as a};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
/*! maintenance_supporter frontend 2.64.0 */
var r=class{constructor(s){this.usersCache=null;this.cacheTimestamp=0;this.CACHE_TTL_MS=6e4;this.hass=s}updateHass(s){this.hass=s}async getUsers(s=!1){let e=Date.now();if(!s&&this.usersCache&&e-this.cacheTimestamp<this.CACHE_TTL_MS)return this.usersCache;try{let t=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/users/list"});return this.usersCache=t.users,this.cacheTimestamp=e,this.usersCache}catch(t){return console.error("Failed to fetch users:",t),this.usersCache||[]}}async assignUser(s,e,t){await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/task/assign_user",entry_id:s,task_id:e,user_id:t})}async getTasksByUser(s){return(await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/tasks/by_user",user_id:s})).tasks}getUserName(s){return!s||!this.usersCache?null:this.usersCache.find(t=>t.id===s)?.name||null}getUser(s){return!s||!this.usersCache?null:this.usersCache.find(e=>e.id===s)||null}getCurrentUserId(){return this.hass.user?.id||null}isCurrentUser(s){return s?s===this.getCurrentUserId():!1}clearCache(){this.usersCache=null,this.cacheTimestamp=0}};export{r as a};
@@ -0,0 +1,2 @@
/*! maintenance_supporter frontend 2.64.0 */
import{a}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-KKUTJDOH.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-DJDTMTMV.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-2AYNLB7B.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-UMHJSVEU.js";export{a as MaintenanceCompleteDialog};
@@ -0,0 +1,2 @@
/*! maintenance_supporter frontend 2.64.0 */
import{k as a,l as b,m as c,n as d,o as e,p as f,q as g,r as h,s as i}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-L2RZLUQG.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-QEGP32JZ.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-UVMKKP2E.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-JMWDEPMQ.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-KKUTJDOH.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-DJDTMTMV.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-2IO2TESP.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-32P7DPCM.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-YHGXWPDQ.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-2AYNLB7B.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-UMHJSVEU.js";export{f as openCompleteDialog,a as openCreateObjectDialog,c as openCreateTaskDialog,b as openEditObjectDialog,d as openEditTaskDialog,e as openHistoryEditDialog,i as openObjectQuickActions,g as openQrDialog,h as openTaskQuickActions};
@@ -0,0 +1,2 @@
/*! maintenance_supporter frontend 2.64.0 */
import{a}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-QEGP32JZ.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-JMWDEPMQ.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-2AYNLB7B.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-UMHJSVEU.js";export{a as MaintenanceObjectDialog};
@@ -0,0 +1,2 @@
/*! maintenance_supporter frontend 2.64.0 */
import{a}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-2IO2TESP.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-32P7DPCM.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-UMHJSVEU.js";export{a as MaintenanceQrDialog};
@@ -0,0 +1,146 @@
/*! maintenance_supporter frontend 2.64.0 */
import{a as g}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-2AYNLB7B.js";import{a as l,b as m,c as i,f as h,g as v,k as u,l as d,p,r as f,t as x}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-UMHJSVEU.js";var a=class extends v{constructor(){super(...arguments);this._open=!1;this._loading=!1;this._adopting=!1;this._error="";this._setups=[];this._selected=new Set;this._baselines=new Map;this._targets=new Map;this._objects=[];this._localeReady=!1;this._toggle=t=>{let e=new Set(this._selected);e.has(t)?e.delete(t):e.add(t),this._selected=e};this._adopt=async()=>{if(!(this._selected.size===0||this._adopting)){this._adopting=!0,this._error="";try{let t=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/integration_setups/adopt",selections:[...this._selected].map(e=>{let r={device_id:e},c=this._targets.get(e);c&&(r.entry_id=c);let s=this._setups.find(n=>n.device_id===e);for(let n of s?.tasks??[]){let o=this._baselines.get(`${e} ${n.task_name}`),_=o?parseFloat(o):NaN;!isNaN(_)&&_>=0&&((r.baselines??={})[n.task_name]=_)}return r})});this.dispatchEvent(new CustomEvent("integration-setups-adopted",{bubbles:!0,composed:!0,detail:t})),this._open=!1}catch(t){this._error=g(t,this._lang)}finally{this._adopting=!1}}}}get _lang(){return f(this.hass)}updated(t){t.has("hass")&&this.hass&&!this._localeReady&&(this._localeReady=!0,x(this._lang).then(()=>this.requestUpdate()))}async open(){this._open=!0,this._loading=!0,this._error="",this._setups=[],this._selected=new Set;try{let t=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/integration_setups/discover"});this._setups=t.setups||[],this._selected=new Set(this._setups.map(e=>e.device_id)),this._baselines=new Map,this._targets=new Map;try{let e=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/objects"});this._objects=(e.objects||[]).map(r=>({entry_id:r.entry_id,name:r.object?.name||r.entry_id})).sort((r,c)=>r.name.localeCompare(c.name))}catch{this._objects=[]}}catch(t){this._error=g(t,this._lang)}finally{this._loading=!1}}_close(){this._open=!1}render(){if(!this._open)return i``;let t=this._lang;return i`
<div class="overlay" @click=${this._close}>
<div class="card" @click=${e=>e.stopPropagation()}>
<div class="title">${p("setups_title",t)}</div>
<div class="hint">${p("setups_hint",t)}</div>
${this._error?i`<div class="error">${this._error}</div>`:h}
${this._loading?i`<div class="loading">…</div>`:this._setups.length===0?i`<div class="empty">${p("setups_none",t)}</div>`:i`
<div class="list">
${this._setups.map(e=>{let r=this._selected.has(e.device_id),c=[e.integration_name,e.area_name].filter(Boolean).join(" \xB7 ");return i`
<label class="row">
<input
type="checkbox"
.checked=${r}
@change=${()=>this._toggle(e.device_id)}
/>
<div class="row-main">
<div class="row-top">
<span class="row-name">${e.device_name}</span>
</div>
<div class="row-sub">${c}</div>
<div class="row-target" @click=${s=>s.preventDefault()}>
${r&&this._objects.length>0?i`
<select
class="target-select"
@change=${s=>{let n=new Map(this._targets),o=s.target.value;o?n.set(e.device_id,o):n.delete(e.device_id),this._targets=n}}
>
<option value="" ?selected=${!this._targets.get(e.device_id)}>
${e.suggested_entry_id?e.suggested_object_name:p("setups_target_new",t).replace("{name}",e.suggested_object_name)}
</option>
${this._objects.filter(s=>s.entry_id!==e.suggested_entry_id).map(s=>i`<option
value=${s.entry_id}
?selected=${this._targets.get(e.device_id)===s.entry_id}
>
${s.name}
</option>`)}
</select>
`:i`${e.suggested_object_name}${e.suggested_entry_id?h:i` <span class="new-tag">${p("adopt_problem_new_object",t)}</span>`}`}
</div>
<div class="row-tasks">
${e.tasks.map(s=>i`<span class="chip" title=${s.entity_ids.join(", ")}>
<ha-icon icon="mdi:link-variant"></ha-icon>${s.task_name_localized||s.task_name}
</span>`)}
</div>
${r?e.tasks.filter(s=>s.direction==="usage_delta").map(s=>{let n=`${e.device_id} ${s.task_name}`;return i`
<div class="baseline-field" @click=${o=>o.preventDefault()}>
<span class="baseline-label"
>${s.task_name_localized||s.task_name}
${p("setups_baseline_hint",t)}</span
>
<input
type="number"
step="any"
min="0"
.value=${this._baselines.get(n)??""}
@click=${o=>o.preventDefault()}
@input=${o=>{let _=new Map(this._baselines);_.set(n,o.target.value),this._baselines=_}}
/>
</div>
`}):h}
</div>
</label>
`})}
</div>
`}
<div class="actions">
<ha-button appearance="plain" @click=${this._close}>
${p("cancel",t)}
</ha-button>
<ha-button
@click=${this._adopt}
.disabled=${this._selected.size===0||this._adopting}
>
${p("setups_adopt",t)}
</ha-button>
</div>
</div>
</div>
`}};a.styles=m`
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.card {
background: var(--card-background-color, #fff);
color: var(--primary-text-color);
border-radius: 12px;
padding: 20px;
display: flex;
flex-direction: column;
gap: 12px;
min-width: min(360px, calc(100vw - 24px));
max-width: 560px;
width: 90vw;
max-height: 80vh;
overflow: hidden;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
}
.title { font-size: 18px; font-weight: 500; }
.hint { color: var(--secondary-text-color); font-size: 13px; }
.error { color: var(--error-color, #f44336); font-size: 13px; }
.loading, .empty { color: var(--secondary-text-color); font-size: 14px; padding: 12px 0; }
.list { display: flex; flex-direction: column; gap: 6px; overflow-y: auto; max-height: 50vh; }
.row {
display: flex; align-items: flex-start; gap: 10px; padding: 8px;
border: 1px solid var(--divider-color); border-radius: 6px; cursor: pointer;
}
.row input { margin-top: 2px; cursor: pointer; }
.row-main { display: flex; flex-direction: column; gap: 3px; min-width: 0; flex: 1; }
.row-name { font-weight: 500; font-size: 13px; }
.row-sub, .row-target { color: var(--secondary-text-color); font-size: 12px; }
.new-tag { font-style: italic; }
.row-tasks { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 2px; }
.chip {
display: inline-flex; align-items: center; gap: 4px;
font-size: 11px; padding: 2px 8px; border-radius: 10px;
background: var(--secondary-background-color, rgba(0, 0, 0, 0.06));
color: var(--primary-text-color); white-space: nowrap;
}
.chip ha-icon { --mdc-icon-size: 12px; color: var(--primary-color); }
.target-select {
font-size: 12px; padding: 2px 4px; max-width: 100%;
border: 1px solid var(--divider-color); border-radius: 4px;
background: var(--card-background-color, #fff);
color: var(--primary-text-color);
}
.baseline-field {
display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
margin-top: 4px; font-size: 12px; color: var(--secondary-text-color);
}
.baseline-field input {
width: 110px; padding: 3px 6px; font-size: 12px;
border: 1px solid var(--divider-color); border-radius: 4px;
background: var(--card-background-color, #fff);
color: var(--primary-text-color);
}
.actions { display: flex; justify-content: flex-end; gap: 8px; padding-top: 8px; }
`,l([u({attribute:!1})],a.prototype,"hass",2),l([d()],a.prototype,"_open",2),l([d()],a.prototype,"_loading",2),l([d()],a.prototype,"_adopting",2),l([d()],a.prototype,"_error",2),l([d()],a.prototype,"_setups",2),l([d()],a.prototype,"_selected",2),l([d()],a.prototype,"_baselines",2),l([d()],a.prototype,"_targets",2),l([d()],a.prototype,"_objects",2);customElements.get("maintenance-suggested-setups-dialog")||customElements.define("maintenance-suggested-setups-dialog",a);export{a as MaintenanceSuggestedSetupsDialog};
@@ -0,0 +1,2 @@
/*! maintenance_supporter frontend 2.64.0 */
import{a}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-UVMKKP2E.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-JMWDEPMQ.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-DJDTMTMV.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-YHGXWPDQ.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-2AYNLB7B.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-UMHJSVEU.js";export{a as MaintenanceTaskDialog};
@@ -0,0 +1,125 @@
/*! maintenance_supporter frontend 2.64.0 */
import{a as m,b as f}from"./chunk-X2VUVVWJ.js";import{a as u,b as l,d as h,e as b,f as y,g as o,h as g,i as a,j as _,l as v,u as p}from"./chunk-D5CMFNAJ.js";import{a as n}from"./chunk-3FHCH2PB.js";var k=80,s=class extends b{constructor(){super(...arguments);this._config={type:""};this._status=null;this._busy=!1;this._error="";this._localMonthly="";this._localYearly="";this._dirty=!1;this._loaded=!1}setConfig(t){this._config=t}getCardSize(){return 2}get _lang(){return _(this.hass)}get _isAdmin(){return this.hass?.user?.is_admin??!0}updated(t){super.updated(t),t.has("hass")&&this.hass&&!this._loaded&&(this._loaded=!0,this._load(),v(this._lang).then(()=>this.requestUpdate()))}async _load(){try{let t=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/budget_status"});this._status=t,this._localMonthly=t.monthly_budget?String(t.monthly_budget):"",this._localYearly=t.yearly_budget?String(t.yearly_budget):"",this._dirty=!1}catch(t){this._error=p(t,this._lang)}}async _save(){if(this._isAdmin){this._busy=!0,this._error="";try{let t=this._localMonthly.trim()===""?0:parseFloat(this._localMonthly),r=this._localYearly.trim()===""?0:parseFloat(this._localYearly),i={};!isNaN(t)&&t>=0&&(i.budget_monthly=t),!isNaN(r)&&r>=0&&(i.budget_yearly=r),await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/global/update",settings:i}),await this._load()}catch(t){this._error=p(t,this._lang)}finally{this._busy=!1}}}_onDeepLink(){history.pushState(null,"","/maintenance-supporter?ms_action=open_budget"),window.dispatchEvent(new CustomEvent("location-changed"))}render(){let t=this._lang,r=this._status;if(!r)return l`<ha-card><div class="loading">${a("loading",t)||"Loading\u2026"}</div></ha-card>`;let i=r.currency_symbol||g,x=r.alert_threshold_pct??k,$=[{label:a("budget_monthly",t)||"Monthly",spent:r.monthly_spent||0,budget:r.monthly_budget||0},{label:a("budget_yearly",t)||"Yearly",spent:r.yearly_spent||0,budget:r.yearly_budget||0}];return l`
<ha-card>
<div class="card-content">
<div class="header">
<div class="title">
<span class="emoji">💰</span>
<span>${this._config.title||a("settings_budget",t)||"Budget"}</span>
</div>
<span class="currency">${i}</span>
</div>
${this._error?l`<div class="error">${this._error}</div>`:h}
${$.map(e=>{if(!(e.budget>0))return l`
<div class="track spent-only">
<div class="track-label-row">
<label>${e.label}</label>
<span class="track-numbers ok">${e.spent.toFixed(0)} ${i}</span>
</div>
</div>
`;let d=Math.min(100,Math.max(0,e.spent/e.budget*100)),c=d>=100?"danger":d>=x?"warning":"ok";return l`
<div class="track">
<div class="track-label-row">
<label>${e.label}</label>
<span class="track-numbers ${c}">
${e.spent.toFixed(0)} / ${e.budget.toFixed(0)} ${i}
</span>
</div>
<div class="bar"><div class="bar-fill ${c}" style="width:${d}%"></div></div>
</div>
`})}
${this._isAdmin?l`
<div class="inputs-row">
<div class="input-field">
<label>${a("budget_monthly_set",t)||"Set monthly"}</label>
<div class="input-wrap">
<input type="number" min="0" step="1"
.value=${this._localMonthly}
?disabled=${this._busy}
@input=${e=>{this._localMonthly=e.target.value,this._dirty=!0}} />
<span class="input-suffix">${i}</span>
</div>
</div>
<div class="input-field">
<label>${a("budget_yearly_set",t)||"Set yearly"}</label>
<div class="input-wrap">
<input type="number" min="0" step="1"
.value=${this._localYearly}
?disabled=${this._busy}
@input=${e=>{this._localYearly=e.target.value,this._dirty=!0}} />
<span class="input-suffix">${i}</span>
</div>
</div>
</div>
<div class="actions">
<button class="btn ${this._dirty?"primary":"muted"}"
@click=${this._save}
?disabled=${this._busy||!this._dirty}>
<ha-icon icon="${this._dirty?"mdi:content-save":"mdi:check"}"></ha-icon>
${this._dirty?a("save",t)||"Save":a("saved",t)||"Saved"}
</button>
<button class="btn link" @click=${this._onDeepLink}>
${a("budget_advanced",t)||"Currency, alerts\u2026"}
</button>
</div>
`:l`
<button class="btn link" @click=${this._onDeepLink}>
${a("budget_open_panel",t)||"Open in panel"}
</button>
`}
</div>
</ha-card>
`}};s.styles=[f,u`
.currency {
font-size: 14px; font-weight: 600;
color: var(--secondary-text-color);
background: var(--secondary-background-color);
padding: 2px 10px; border-radius: 999px;
}
.track { display: flex; flex-direction: column; gap: 4px; }
.track-label-row {
display: flex; align-items: center; justify-content: space-between;
}
.track-label-row label {
font-size: 12px; color: var(--secondary-text-color);
text-transform: uppercase; letter-spacing: 0.5px;
}
.track-numbers { font-size: 13px; font-weight: 600; }
.track-numbers.ok { color: var(--primary-text-color); }
.track-numbers.warning { color: #ff9800; }
.track-numbers.danger { color: var(--error-color, #f44336); }
.bar {
height: 6px; background: var(--secondary-background-color);
border-radius: 3px; overflow: hidden;
}
.bar-fill { height: 100%; transition: width 0.3s; border-radius: 3px; }
.bar-fill.ok { background: var(--primary-color); }
.bar-fill.warning { background: #ff9800; }
.bar-fill.danger { background: var(--error-color, #f44336); }
.inputs-row {
display: grid; grid-template-columns: 1fr 1fr; gap: 8px;
padding-top: 4px; border-top: 1px solid var(--divider-color);
}
.input-field { display: flex; flex-direction: column; gap: 4px; }
.input-field label {
font-size: 11px; color: var(--secondary-text-color);
text-transform: uppercase; letter-spacing: 0.3px;
}
.input-wrap { position: relative; display: flex; align-items: center; }
.input-wrap input {
flex: 1; padding: 6px 32px 6px 8px; font-size: 13px;
background: var(--secondary-background-color, #2c2c2c);
color: var(--primary-text-color);
border: 1px solid var(--divider-color); border-radius: 6px;
font-family: inherit;
}
.input-suffix {
position: absolute; right: 8px;
color: var(--secondary-text-color); font-size: 13px;
pointer-events: none;
}
.actions { display: flex; gap: 8px; align-items: center; }
`],n([y({attribute:!1})],s.prototype,"hass",2),n([o()],s.prototype,"_config",2),n([o()],s.prototype,"_status",2),n([o()],s.prototype,"_busy",2),n([o()],s.prototype,"_error",2),n([o()],s.prototype,"_localMonthly",2),n([o()],s.prototype,"_localYearly",2),n([o()],s.prototype,"_dirty",2);customElements.get("maintenance-budget-section-card")||customElements.define("maintenance-budget-section-card",s);m({type:"maintenance-budget-section-card",name:"Maintenance Supporter \u2014 Budget",description:"Inline monthly + yearly budget editor",preview:!1});export{s as MaintenanceBudgetSectionCard};
@@ -0,0 +1,2 @@
/*! maintenance_supporter frontend 2.64.0 */
var s=Object.defineProperty;var l=Object.getOwnPropertyDescriptor;var t=(a,r,c,o)=>{for(var e=o>1?void 0:o?l(r,c):r,i=a.length-1,d;i>=0;i--)(d=a[i])&&(e=(o?d(r,c,e):d(e))||e);return o&&e&&s(r,c,e),e};var m={ok:"var(--success-color, #4caf50)",due_soon:"var(--warning-color, #ff9800)",overdue:"var(--error-color, #f44336)",triggered:"var(--deep-orange-color, #ff5722)",archived:"var(--disabled-color, #9e9e9e)",paused:"var(--info-color, #2196f3)"},v={ok:"mdi:check-circle",due_soon:"mdi:alert-circle",overdue:"mdi:alert-octagon",triggered:"mdi:bell-alert",archived:"mdi:archive-outline",paused:"mdi:pause-circle-outline",completed:"mdi:check-circle",skipped:"mdi:skip-next",missed:"mdi:calendar-remove",reset:"mdi:refresh"};export{t as a,m as b,v as c};
File diff suppressed because one or more lines are too long
@@ -0,0 +1,60 @@
/*! maintenance_supporter frontend 2.64.0 */
import{a as t}from"./chunk-D5CMFNAJ.js";function e(o){let r=window;r.customCards=r.customCards||[],r.customCards.some(a=>a.type===o.type)||r.customCards.push(o)}var d=t`
ha-card { overflow: hidden; }
.card-content {
padding: 16px;
display: flex; flex-direction: column;
gap: 12px;
}
.header {
display: flex; align-items: center; justify-content: space-between;
gap: 12px;
}
.title {
display: flex; align-items: center; gap: 8px;
font-size: 16px; font-weight: 500;
}
.emoji { font-size: 20px; }
/* Button family — primary action / muted-saved-state / link / icon-with-text */
.btn {
padding: 6px 12px; font-size: 13px;
border-radius: 6px; cursor: pointer;
border: 1px solid var(--divider-color);
background: var(--secondary-background-color, transparent);
color: var(--primary-text-color);
font-weight: 500;
display: inline-flex; align-items: center; gap: 4px;
}
.btn:hover { background: var(--state-icon-color, rgba(255,255,255,0.06)); }
.btn[disabled] { opacity: 0.5; cursor: not-allowed; }
.btn.primary {
background: var(--primary-color);
color: var(--text-primary-color, white);
border-color: var(--primary-color);
}
.btn.primary[disabled] { opacity: 0.6; }
.btn.muted {
background: transparent;
color: var(--secondary-text-color);
border-style: dashed;
}
.btn.muted[disabled] { opacity: 1; cursor: default; }
.btn.muted ha-icon, .btn.primary ha-icon { --mdc-icon-size: 14px; }
.btn.link {
background: transparent; border: none; padding: 6px 4px;
color: var(--primary-color); margin-left: auto;
}
.btn.link:hover { background: transparent; text-decoration: underline; }
/* Error + loading states */
.error {
padding: 8px; border-radius: 6px;
background: rgba(211, 47, 47, 0.1);
color: var(--error-color, #d32f2f); font-size: 13px;
}
.loading {
padding: 24px; text-align: center;
color: var(--secondary-text-color);
}
`;export{e as a,d as b};
File diff suppressed because one or more lines are too long
@@ -0,0 +1,137 @@
/*! maintenance_supporter frontend 2.64.0 */
import{a as v,b}from"./chunk-X2VUVVWJ.js";import{a as u,b as s,d as c,e as h,f as g,g as o,i,j as _,l as m,u as p}from"./chunk-D5CMFNAJ.js";import{a}from"./chunk-3FHCH2PB.js";var e=class extends h{constructor(){super(...arguments);this._config={type:""};this._groups={};this._loaded=!1;this._busy=!1;this._error="";this._newName="";this._editingId=null;this._editingName="";this._hasInitiallyLoaded=!1}setConfig(t){this._config=t}getCardSize(){return 2}get _lang(){return _(this.hass)}get _isAdmin(){return this.hass?.user?.is_admin??!0}updated(t){super.updated(t),t.has("hass")&&this.hass&&!this._hasInitiallyLoaded&&(this._hasInitiallyLoaded=!0,this._load(),m(this._lang).then(()=>this.requestUpdate()))}async _load(){try{let t=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/groups"});this._groups=t.groups||{},this._loaded=!0}catch(t){this._error=p(t,this._lang)}}async _addGroup(){if(!this._isAdmin)return;let t=this._newName.trim();if(t){this._busy=!0,this._error="";try{await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/group/create",name:t}),this._newName="",await this._load()}catch(r){this._error=p(r,this._lang)}finally{this._busy=!1}}}_startEdit(t){this._editingId=t,this._editingName=this._groups[t]?.name||""}async _saveEdit(){if(!this._isAdmin||!this._editingId)return;let t=this._editingName.trim();if(t){this._busy=!0,this._error="";try{await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/group/update",group_id:this._editingId,name:t}),this._editingId=null,this._editingName="",await this._load()}catch(r){this._error=p(r,this._lang)}finally{this._busy=!1}}}async _deleteGroup(t,r){if(!this._isAdmin)return;let n=(i("group_delete_confirm",this._lang)||'Delete group "{name}"?').replace("{name}",r);if(window.confirm(n)){this._busy=!0;try{await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/group/delete",group_id:t}),await this._load()}catch(d){this._error=p(d,this._lang)}finally{this._busy=!1}}}_onDeepLink(){history.pushState(null,"","/maintenance-supporter?ms_action=open_groups"),window.dispatchEvent(new CustomEvent("location-changed"))}_onKeyDown(t,r){t.key==="Enter"?(t.preventDefault(),r()):t.key==="Escape"&&(t.preventDefault(),this._editingId=null,this._editingName="")}render(){let t=this._lang;if(!this._loaded)return s`<ha-card><div class="loading">${i("loading",t)||"Loading\u2026"}</div></ha-card>`;let r=Object.keys(this._groups);return s`
<ha-card>
<div class="card-content">
<div class="header">
<div class="title">
<span class="emoji">🏷</span>
<span>${this._config.title||i("groups",t)||"Groups"}</span>
<span class="count">${r.length}</span>
</div>
</div>
${this._error?s`<div class="error">${this._error}</div>`:c}
${r.length===0?s`<div class="empty">${i("groups_empty",t)||"No groups yet."}</div>`:s`
<div class="group-list">
${r.map(n=>{let d=this._groups[n],y=d.task_refs?.length??0,f=this._editingId===n;return s`
<div class="group-row">
${f?s`
<input class="edit-input" type="text"
.value=${this._editingName}
?disabled=${this._busy}
@input=${l=>{this._editingName=l.target.value}}
@keydown=${l=>this._onKeyDown(l,this._saveEdit.bind(this))} />
<button class="btn small primary"
@click=${this._saveEdit}
?disabled=${this._busy||!this._editingName.trim()}>
${i("save",t)||"Save"}
</button>
<button class="btn small"
@click=${()=>{this._editingId=null}}>
${i("cancel",t)||"Cancel"}
</button>
`:s`
<span class="group-name">${d.name||"Unnamed"}</span>
<span class="task-count">${y}</span>
${this._isAdmin?s`
<button class="icon-btn"
title="${i("edit",t)||"Edit"}"
@click=${()=>this._startEdit(n)}
?disabled=${this._busy}>
<ha-icon icon="mdi:pencil"></ha-icon>
</button>
<button class="icon-btn danger"
title="${i("delete",t)||"Delete"}"
@click=${()=>this._deleteGroup(n,d.name||"Unnamed")}
?disabled=${this._busy}>
<ha-icon icon="mdi:delete"></ha-icon>
</button>
`:c}
`}
</div>
`})}
</div>
`}
${this._isAdmin?s`
<div class="add-row">
<input type="text"
placeholder="${i("group_new_placeholder",t)||"Add group\u2026"}"
.value=${this._newName}
?disabled=${this._busy}
@input=${n=>{this._newName=n.target.value}}
@keydown=${n=>this._onKeyDown(n,this._addGroup.bind(this))} />
<button class="btn primary"
@click=${this._addGroup}
?disabled=${this._busy||!this._newName.trim()}>
<ha-icon icon="mdi:plus"></ha-icon>
${i("add",t)||"Add"}
</button>
</div>
<button class="btn link" @click=${this._onDeepLink}>
${i("groups_manage_tasks",t)||"Manage task assignments\u2026"}
</button>
`:s`
<button class="btn link" @click=${this._onDeepLink}>
${i("groups_open_panel",t)||"Open in panel"}
</button>
`}
</div>
</ha-card>
`}};e.styles=[b,u`
.count {
font-size: 12px; color: var(--secondary-text-color);
background: var(--secondary-background-color);
padding: 2px 8px; border-radius: 999px;
}
.empty {
padding: 16px; text-align: center;
color: var(--secondary-text-color); font-style: italic;
}
.group-list { display: flex; flex-direction: column; gap: 4px; }
.group-row {
display: flex; align-items: center; gap: 8px;
padding: 6px 8px; border-radius: 6px;
background: var(--secondary-background-color, rgba(255,255,255,0.03));
}
.group-name { flex: 1; font-size: 14px; }
.task-count {
font-size: 11px; color: var(--secondary-text-color);
background: var(--card-background-color, rgba(0,0,0,0.2));
padding: 1px 8px; border-radius: 999px;
font-weight: 500;
}
.edit-input {
flex: 1; padding: 4px 8px; font-size: 14px;
background: var(--card-background-color, #1c1c1c);
color: var(--primary-text-color);
border: 1px solid var(--primary-color); border-radius: 4px;
font-family: inherit;
}
.icon-btn {
background: transparent; border: none; cursor: pointer;
color: var(--secondary-text-color); padding: 4px;
border-radius: 4px;
}
.icon-btn:hover {
background: var(--state-icon-color, rgba(255,255,255,0.06));
color: var(--primary-text-color);
}
.icon-btn.danger:hover { color: var(--error-color); }
.icon-btn ha-icon { --mdc-icon-size: 18px; }
.add-row {
display: flex; gap: 6px;
padding-top: 8px; border-top: 1px solid var(--divider-color);
}
.add-row input {
flex: 1; padding: 6px 8px; font-size: 13px;
background: var(--secondary-background-color, #2c2c2c);
color: var(--primary-text-color);
border: 1px solid var(--divider-color); border-radius: 6px;
font-family: inherit;
}
/* Card-specific overrides on the shared .btn */
.btn.small { padding: 4px 8px; font-size: 12px; }
.btn ha-icon { --mdc-icon-size: 16px; }
`],a([g({attribute:!1})],e.prototype,"hass",2),a([o()],e.prototype,"_config",2),a([o()],e.prototype,"_groups",2),a([o()],e.prototype,"_loaded",2),a([o()],e.prototype,"_busy",2),a([o()],e.prototype,"_error",2),a([o()],e.prototype,"_newName",2),a([o()],e.prototype,"_editingId",2),a([o()],e.prototype,"_editingName",2);customElements.get("maintenance-groups-section-card")||customElements.define("maintenance-groups-section-card",e);v({type:"maintenance-groups-section-card",name:"Maintenance Supporter \u2014 Groups",description:"Inline group CRUD",preview:!1});export{e as MaintenanceGroupsSectionCard};
@@ -0,0 +1,122 @@
/*! maintenance_supporter frontend 2.64.0 */
import{a as m,b as g}from"./chunk-X2VUVVWJ.js";import{a as h,b as n,d as c,e as _,f as v,g as r,i as e,j as f,l as b,u as l}from"./chunk-D5CMFNAJ.js";import{a as i}from"./chunk-3FHCH2PB.js";var a=class extends _{constructor(){super(...arguments);this._config={type:""};this._state=null;this._busy=!1;this._error="";this._localStart="";this._localEnd="";this._localBuffer=7;this._dirty=!1;this._loaded=!1}setConfig(t){this._config=t}getCardSize(){return 2}get _lang(){return f(this.hass)}get _isAdmin(){return this.hass?.user?.is_admin??!0}updated(t){super.updated(t),t.has("hass")&&this.hass&&!this._loaded&&(this._loaded=!0,this._load(),b(this._lang).then(()=>this.requestUpdate()))}async _load(){try{let t=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/vacation/state"});this._state=t,this._localStart=t.start||"",this._localEnd=t.end||"",this._localBuffer=t.buffer_days??7,this._dirty=!1}catch(t){this._error=l(t,this._lang)}}async _toggleEnabled(t){this._busy=!0,this._error="";try{let s=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/vacation/update",enabled:t});this._state=s}catch(s){this._error=l(s,this._lang)}finally{this._busy=!1}}async _save(){if(this._isAdmin){this._busy=!0,this._error="";try{let t=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/vacation/update",start:this._localStart||null,end:this._localEnd||null,buffer_days:this._localBuffer});this._state=t,this._dirty=!1}catch(t){this._error=l(t,this._lang)}finally{this._busy=!1}}}async _endNow(){if(this._isAdmin&&window.confirm(e("vacation_end_now_confirm",this._lang)||"End vacation immediately?")){this._busy=!0;try{let t=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/vacation/end_now"});this._state=t,this._localStart=t.start||"",this._localEnd=t.end||""}catch(t){this._error=l(t,this._lang)}finally{this._busy=!1}}}_onDeepLink(){history.pushState(null,"","/maintenance-supporter?ms_action=open_vacation"),window.dispatchEvent(new CustomEvent("location-changed"))}render(){let t=this._lang,s=this._state;if(!s)return n`<ha-card><div class="loading">${e("loading",t)||"Loading\u2026"}</div></ha-card>`;let p=s.is_active===!0,d=s.enabled===!0,u=s.exempt_task_ids?.length??0,y=p?e("vacation_status_active",t)||"Active now":d?e("vacation_status_scheduled",t)||"Scheduled":e("vacation_status_inactive",t)||"Inactive",$=p?"active":d?"scheduled":"inactive";return n`
<ha-card>
<div class="card-content">
<div class="header">
<div class="title">
<span class="emoji">🏖</span>
<span>${this._config.title||e("vacation_mode",t)||"Vacation mode"}</span>
</div>
<span class="status-pill ${$}">${y}</span>
</div>
${this._error?n`<div class="error">${this._error}</div>`:c}
${this._isAdmin?n`
<div class="row toggle-row">
<label>${e("enable",t)||"Enable"}</label>
<ha-switch
.checked=${d}
.disabled=${this._busy}
@change=${o=>this._toggleEnabled(o.target.checked)}
></ha-switch>
</div>
<div class="dates-row">
<div class="date-field">
<label>${e("vacation_start",t)||"Start"}</label>
<input type="date" .value=${this._localStart}
?disabled=${this._busy}
@input=${o=>{this._localStart=o.target.value,this._dirty=!0}} />
</div>
<div class="date-field">
<label>${e("vacation_end",t)||"End"}</label>
<input type="date" .value=${this._localEnd}
?disabled=${this._busy}
@input=${o=>{this._localEnd=o.target.value,this._dirty=!0}} />
</div>
<div class="date-field buffer">
<label>${e("vacation_buffer",t)||"Buffer days"}</label>
<input type="number" min="0" max="14"
.value=${String(this._localBuffer)}
?disabled=${this._busy}
@input=${o=>{this._localBuffer=parseInt(o.target.value,10)||0,this._dirty=!0}} />
</div>
</div>
<div class="actions">
<button class="btn ${this._dirty?"primary":"muted"}"
@click=${this._save}
?disabled=${this._busy||!this._dirty}>
<ha-icon icon="${this._dirty?"mdi:content-save":"mdi:check"}"></ha-icon>
${this._dirty?e("save",t)||"Save":e("saved",t)||"Saved"}
</button>
${p?n`<button class="btn"
@click=${this._endNow}
?disabled=${this._busy}>
${e("vacation_end_now",t)||"End now"}
</button>`:c}
${u>0?n`<button class="btn link"
@click=${this._onDeepLink}>
${u} ${e("vacation_exempt_count",t)||"exempt"}
</button>`:n`<button class="btn link"
@click=${this._onDeepLink}>
${e("vacation_advanced",t)||"Advanced\u2026"}
</button>`}
</div>
`:n`
<div class="readonly">
${d&&s.start&&s.end?n`<div>${s.start}${s.end}</div>`:c}
<button class="btn link" @click=${this._onDeepLink}>
${e("vacation_open_panel",t)||"Open in panel"}
</button>
</div>
`}
</div>
</ha-card>
`}};a.styles=[g,h`
.status-pill {
font-size: 11px; font-weight: 600;
padding: 3px 8px; border-radius: 999px;
text-transform: uppercase; letter-spacing: 0.5px;
}
.status-pill.active {
background: rgba(76, 175, 80, 0.15);
color: #4caf50;
}
.status-pill.scheduled {
background: rgba(255, 152, 0, 0.15);
color: #ff9800;
}
.status-pill.inactive {
background: rgba(158, 158, 158, 0.15);
color: var(--secondary-text-color);
}
.row.toggle-row {
display: flex; align-items: center; justify-content: space-between;
}
.row.toggle-row label {
font-size: 14px; color: var(--primary-text-color);
}
.dates-row {
display: grid; grid-template-columns: 1fr 1fr 100px; gap: 10px;
}
.date-field.buffer label { white-space: nowrap; }
.date-field { display: flex; flex-direction: column; gap: 4px; }
.date-field label {
font-size: 11px; color: var(--secondary-text-color);
text-transform: uppercase; letter-spacing: 0.3px;
}
.date-field input {
padding: 6px 8px; font-size: 13px;
background: var(--secondary-background-color, #2c2c2c);
color: var(--primary-text-color);
border: 1px solid var(--divider-color); border-radius: 6px;
font-family: inherit;
}
.date-field input:disabled { opacity: 0.5; cursor: not-allowed; }
.actions {
display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
}
.readonly { display: flex; flex-direction: column; gap: 8px; }
`],i([v({attribute:!1})],a.prototype,"hass",2),i([r()],a.prototype,"_config",2),i([r()],a.prototype,"_state",2),i([r()],a.prototype,"_busy",2),i([r()],a.prototype,"_error",2),i([r()],a.prototype,"_localStart",2),i([r()],a.prototype,"_localEnd",2),i([r()],a.prototype,"_localBuffer",2),i([r()],a.prototype,"_dirty",2);customElements.get("maintenance-vacation-section-card")||customElements.define("maintenance-vacation-section-card",a);m({type:"maintenance-vacation-section-card",name:"Maintenance Supporter \u2014 Vacation",description:"Inline vacation mode toggle + dates",preview:!1});export{a as MaintenanceVacationSectionCard};
File diff suppressed because one or more lines are too long
@@ -254,9 +254,17 @@ def seed_rotation_assignee(task_data: dict[str, Any]) -> None:
(the pool was edited out from under the current assignee).
"""
pool = [u for u in task_data.get("assignee_pool") or [] if u]
if len(pool) < 2 or not task_data.get("rotation_strategy"):
if not pool or not task_data.get("rotation_strategy"):
return
if task_data.get("responsible_user_id") not in pool:
current = task_data.get("responsible_user_id")
# A pool of one is an inert rotation — never seed a MISSING assignee for
# it. But a STALE one must still be corrected: the old blanket
# `len(pool) < 2` early-return meant a pool edited down to one member
# never ran the not-in-pool check, so a removed assignee kept the task
# forever (bug audit 2026-08-22).
if len(pool) < 2 and not current:
return
if current not in pool:
task_data["responsible_user_id"] = pool[0]
@@ -54,18 +54,74 @@ _MAX_PLANNED_STEPS = 2000
_MAX_OFFSET_DAYS = 15
def _coerce_int(raw: object) -> int | None:
"""Best-effort int from a persisted/imported value.
Ints pass, integral floats and numeric strings coerce, everything else
-> None. Imports and hand-edited payloads carried e.g. ``every: "30"`` or
``nth: 2.0``, which raised TypeError on EVERY refresh one bad field took
the whole object's sensors down (bug audit 2026-08-22). The read path must
degrade to "no value", never crash.
"""
if isinstance(raw, bool):
return None
if isinstance(raw, int):
return raw
if isinstance(raw, float):
return int(raw) if raw.is_integer() else None
if isinstance(raw, str):
try:
val = float(raw.strip())
except ValueError:
return None
return int(val) if val.is_integer() else None
return None
def _sanitize_every(raw: object) -> int | None:
"""Interval count >= 1, or None."""
val = _coerce_int(raw)
return val if val is not None and val >= 1 else None
def _sanitize_nth(raw: object) -> int | None:
"""nth 1..5, or -1 = last occurrence; anything else -> None."""
val = _coerce_int(raw)
if val is None:
return None
if val == -1 or 1 <= val <= 5:
return val
return None
def _sanitize_weekday(raw: object) -> int | None:
"""Weekday 0=Mon..6=Sun, or None."""
val = _coerce_int(raw)
return val if val is not None and 0 <= val <= 6 else None
def _sanitize_weekdays(raw: object) -> tuple[int, ...]:
"""A deduped, sorted tuple of valid weekdays (0..6); () on garbage."""
if not isinstance(raw, (list, tuple)):
return ()
seen = {wd for item in raw if (wd := _sanitize_weekday(item)) is not None}
return tuple(sorted(seen))
def _sanitize_offset(raw: object) -> int:
if isinstance(raw, bool) or not isinstance(raw, int):
val = _coerce_int(raw)
if val is None:
return 0
return max(-_MAX_OFFSET_DAYS, min(raw, _MAX_OFFSET_DAYS))
return max(-_MAX_OFFSET_DAYS, min(val, _MAX_OFFSET_DAYS))
def _sanitize_day(raw: object) -> int | None:
"""day 1..31, or -1 = last day of the month; anything else -> None."""
if isinstance(raw, bool) or not isinstance(raw, int):
val = _coerce_int(raw)
if val is None:
return None
if raw == -1 or 1 <= raw <= 31:
return raw
if val == -1 or 1 <= val <= 31:
return val
return None
@@ -73,7 +129,7 @@ def _sanitize_months(raw: object) -> tuple[int, ...]:
"""A deduped, sorted tuple of valid month numbers (1..12); [] on garbage."""
if not isinstance(raw, (list, tuple)):
return ()
seen = {m for m in raw if isinstance(m, int) and not isinstance(m, bool) and 1 <= m <= 12}
seen = {m for item in raw if (m := _coerce_int(item)) is not None and 1 <= m <= 12}
return tuple(sorted(seen))
@@ -81,8 +137,8 @@ def _parse_ends(raw: object) -> tuple[int | None, date | None]:
"""Read a finite-series ``ends`` block: (count>=1 or None, until-date or None)."""
if not isinstance(raw, Mapping):
return None, None
count = raw.get("count")
valid_count = count if isinstance(count, int) and not isinstance(count, bool) and count >= 1 else None
count = _coerce_int(raw.get("count"))
valid_count = count if count is not None and count >= 1 else None
until_raw = raw.get("until")
until = parse_iso_date(until_raw) if isinstance(until_raw, str) else None
return valid_count, until
@@ -137,11 +193,14 @@ class Schedule:
"""
if schedule_type == KIND_ONE_TIME:
return cls(kind=KIND_ONE_TIME, due_date=parse_iso_date(due_date))
if not interval_days or interval_days <= 0:
# Coerce — an imported flat payload can carry interval_days as a
# string, and `"30" <= 0` raises TypeError on every refresh.
every = _sanitize_every(interval_days)
if every is None:
return cls(kind=KIND_MANUAL)
return cls(
kind=KIND_INTERVAL,
every=interval_days,
every=every,
unit=interval_unit or "days",
anchor=interval_anchor or "completion",
)
@@ -401,7 +460,7 @@ class Schedule:
if kind == KIND_INTERVAL:
return cls(
kind=KIND_INTERVAL,
every=d.get("every"),
every=_sanitize_every(d.get("every")),
unit=d.get("unit") or "days",
anchor=d.get("anchor") or "completion",
season_months=season,
@@ -411,7 +470,7 @@ class Schedule:
if kind == KIND_WEEKDAYS:
return cls(
kind=KIND_WEEKDAYS,
weekdays=tuple(d.get("weekdays") or ()),
weekdays=_sanitize_weekdays(d.get("weekdays")),
offset_days=_sanitize_offset(d.get("offset")),
season_months=season,
ends_count=ends_count,
@@ -420,9 +479,9 @@ class Schedule:
if kind == KIND_NTH_WEEKDAY:
return cls(
kind=KIND_NTH_WEEKDAY,
nth=d.get("nth"),
weekday=d.get("weekday"),
months=tuple(d.get("months") or ()),
nth=_sanitize_nth(d.get("nth")),
weekday=_sanitize_weekday(d.get("weekday")),
months=_sanitize_months(d.get("months")),
offset_days=_sanitize_offset(d.get("offset")),
season_months=season,
ends_count=ends_count,
@@ -432,7 +491,7 @@ class Schedule:
return cls(
kind=KIND_DAY_OF_MONTH,
day=_sanitize_day(d.get("day")),
months=tuple(d.get("months") or ()),
months=_sanitize_months(d.get("months")),
business=d.get("business") is True,
offset_days=_sanitize_offset(d.get("offset")),
season_months=season,
@@ -99,6 +99,11 @@ def evaluate_threshold(
equals = trigger_config.get("trigger_equals")
not_equals = trigger_config.get("trigger_not_equals")
# No entities at all = nothing can ever latch — "not triggered" is a safe
# verdict (and the boundary pin the mutation suite asserts).
if not entity_ids:
return FallbackResult(current_value=None, active=False)
per_entity: list[bool] = []
last_value: float | None = None
for eid in entity_ids:
@@ -113,7 +118,13 @@ def evaluate_threshold(
active: bool | None
if for_minutes == 0:
active = aggregated
# Only assert a verdict when at least one entity produced a READING.
# base_trigger deliberately keeps the latch through unavailable blips
# ("unavailable carries no measurement"); this sweep used to overrule
# it — a 90 s sensor dropout inside the 5-min window flipped a latched
# task OK and back, firing state automations twice (bug audit
# 2026-08-22). Mirrors the guard the for_minutes>0 branch always had.
active = aggregated if last_value is not None else None
elif not aggregated and last_value is not None:
# Back in the normal range — safe to deactivate even with for_minutes.
active = False
@@ -178,13 +189,23 @@ def evaluate_state_change(
# Legacy flat storage
cc = trigger_config.get("trigger_change_count")
if cc is None:
# Never persisted = zero transitions. Contribute False instead of
# skipping: omitting the entity made "all" quantify over a subset,
# so one busy door could satisfy an all-of-two config while the
# other door had never moved (bug audit 2026-08-22 — mirrors
# evaluate_threshold/counter, which already append False).
if target_changes:
per_entity.append(False)
continue
count = float(cc)
best_count = count if best_count is None else max(best_count, count)
if target_changes:
per_entity.append(count >= target_changes)
active = _aggregate(per_entity, entity_logic) if per_entity else None
# No entity has EVER persisted a count → the fallback has nothing to say
# (active=None keeps the event-driven state untouched). The False
# contributions above only matter once at least one real count exists.
active = _aggregate(per_entity, entity_logic) if per_entity and best_count is not None else None
return FallbackResult(current_value=best_count, active=active)
@@ -203,6 +224,11 @@ def evaluate_runtime(
es = trigger_state.get(eid, {})
seconds = es.get("accumulated_seconds")
if seconds is None:
# Never persisted = zero runtime. Contribute False so an "all"
# config cannot be satisfied by a subset (bug audit 2026-08-22;
# mirrors evaluate_threshold/counter/state_change).
if target_hours:
per_entity.append(False)
continue
total = float(seconds)
on_since = es.get("on_since")
@@ -222,7 +248,9 @@ def evaluate_runtime(
if target_hours:
per_entity.append(hours >= target_hours)
active = _aggregate(per_entity, entity_logic) if per_entity else None
# Same rule as evaluate_state_change: without a single persisted value
# the fallback stays silent instead of asserting False.
active = _aggregate(per_entity, entity_logic) if per_entity and best_hours is not None else None
return FallbackResult(
current_value=round(best_hours, 2) if best_hours is not None else None,
active=active,
@@ -22,5 +22,5 @@
"requirements": [
"pypdf>=4.3.0"
],
"version": "2.63.1"
"version": "2.64.0"
}
@@ -136,6 +136,30 @@ class MaintenanceTask:
due_override=parse_iso_date(self.due_override),
)
def _planned_grid_due(self) -> date | None:
"""``next_due`` WITHOUT the postpone override — the drift-free grid.
The planned-anchor update in complete()/skip() must anchor on the
GRID date, not on ``next_due``: that property returns ``due_override``
when one is set, so anchoring on it made a one-shot postpone shift
the whole cadence permanently (bug audit 2026-08-22 a 19-day
postpone moved every future occurrence by 19 days forever).
"""
last: date | None = None
if self.last_performed:
try:
last = date.fromisoformat(self.last_performed)
except (ValueError, TypeError):
return None
return self._schedule().next_due(
last_performed=last,
created_at=parse_iso_date(self.created_at),
last_planned_due=parse_iso_date(self.last_planned_due),
today=dt_util.now().date(),
times_performed=self.times_performed,
due_override=None,
)
def _schedule(self) -> Schedule:
"""The recurrence as a value object (see docs/design/schedule-model-v2.md).
@@ -352,9 +376,13 @@ class MaintenanceTask:
is_latest = ts_iso >= max(anchors, default="")
if is_latest:
# Save current next_due as anchor for planned mode before resetting
if self.interval_anchor == "planned" and self.next_due is not None:
self.last_planned_due = self.next_due.isoformat()
# Save the PLANNED grid date as the anchor before resetting — not
# next_due, which returns a postpone override and would shift the
# cadence permanently (see _planned_grid_due).
if self.interval_anchor == "planned":
grid = self._planned_grid_due()
if grid is not None:
self.last_planned_due = grid.isoformat()
self.last_performed = ts.date().isoformat()
self._trigger_active = False
@@ -423,8 +451,14 @@ class MaintenanceTask:
if reset_date is None:
reset_date = dt_util.now().date()
self.last_performed = reset_date.isoformat()
# Clear planned anchor so next_due is computed from the reset date
# Clear BOTH cycle modifiers so next_due is computed from the reset
# date. due_override was forgotten here (bug audit 2026-08-22): a
# postponed task that was reset kept the override, so the reset
# visibly did nothing to the due date. Mirrors skip() and
# helpers/pause.clear_cycle_modifiers ("both must go when a task is
# re-anchored").
self.last_planned_due = None
self.due_override = None
self.add_history_entry(
entry_type=HistoryEntryType.RESET,
@@ -438,9 +472,13 @@ class MaintenanceTask:
rather than a deliberate SKIPPED clearer history + compliance views.
The cycle restarts either way.
"""
# Save current next_due as anchor for planned mode before resetting
if self.interval_anchor == "planned" and self.next_due is not None:
self.last_planned_due = self.next_due.isoformat()
# Save the PLANNED grid date as the anchor before resetting — not
# next_due, which returns a postpone override (same cadence-shift bug
# as complete(); see _planned_grid_due).
if self.interval_anchor == "planned":
grid = self._planned_grid_due()
if grid is not None:
self.last_planned_due = grid.isoformat()
# Move last_performed to today to restart the cycle
self.last_performed = dt_util.now().date().isoformat()
@@ -345,6 +345,15 @@ class MaintenanceSensor(MaintenanceEntity, SensorEntity):
entity=self,
trigger_config=trigger_config,
)
# Pre-seed the aggregation map with False for EVERY entity — the
# same H1 fix compound.py already carries: the map used to start
# empty and fill only on a trigger's first TRANSITION, so with
# entity_logic "all" the very first entity to trigger made
# all({one: True}) pass and falsely activated the task while the
# sibling sensors were still fine (bug audit 2026-08-22). Setup
# below overwrites entries for entities that restore triggered.
if len(self._triggers) > 1:
self._trigger_states = {t.entity_id: False for t in self._triggers}
for trigger in self._triggers:
await trigger.async_setup()
_LOGGER.debug(

Some files were not shown because too many files have changed in this diff Show More