124 files
This commit is contained in:
@@ -1204,6 +1204,12 @@ class AlbumSlideshowCamera(Camera):
|
||||
url, len(data), _MAX_DOWNLOAD_BYTES,
|
||||
)
|
||||
return None
|
||||
elif not url.startswith("http"):
|
||||
# End-to-end encrypted providers (Ente) can't be fetched by URL:
|
||||
# the coordinator downloads and decrypts the bytes for us.
|
||||
data = await self._provider_bytes(url)
|
||||
if data is None:
|
||||
return None
|
||||
else:
|
||||
data = await self._http_get(url)
|
||||
if data is None:
|
||||
@@ -1212,6 +1218,27 @@ class AlbumSlideshowCamera(Camera):
|
||||
self._download_cache.put(url, data)
|
||||
return data
|
||||
|
||||
async def _provider_bytes(self, url: str) -> bytes | None:
|
||||
"""Ask the coordinator for decrypted bytes behind a provider URL."""
|
||||
fetcher = getattr(self.coordinator, "async_fetch_image_bytes", None)
|
||||
if fetcher is None:
|
||||
_LOGGER.warning("Album Slideshow: no handler for image url %s", url)
|
||||
return None
|
||||
try:
|
||||
data = await fetcher(url)
|
||||
except Exception as err: # noqa: BLE001
|
||||
_LOGGER.warning("Album Slideshow: failed to fetch %s: %s", url, err)
|
||||
return None
|
||||
if data is None:
|
||||
return None
|
||||
if len(data) > _MAX_DOWNLOAD_BYTES:
|
||||
_LOGGER.warning(
|
||||
"Album Slideshow: %s is %d bytes, exceeds %d byte limit; skipping",
|
||||
url, len(data), _MAX_DOWNLOAD_BYTES,
|
||||
)
|
||||
return None
|
||||
return data
|
||||
|
||||
def _image_request_headers(self, url: str) -> dict[str, str] | None:
|
||||
"""Auth headers required to fetch image bytes for some providers.
|
||||
|
||||
|
||||
@@ -78,9 +78,22 @@ from .const import (
|
||||
CONF_NEXTCLOUD_FOLDER,
|
||||
CONF_NEXTCLOUD_RECURSIVE,
|
||||
CONF_NEXTCLOUD_IMAGE_SIZE,
|
||||
CONF_NEXTCLOUD_SHARE_TOKEN,
|
||||
CONF_NEXTCLOUD_AUTH_MODE,
|
||||
NEXTCLOUD_AUTH_MODE_FOLDER,
|
||||
NEXTCLOUD_AUTH_MODE_PUBLIC,
|
||||
DEFAULT_NEXTCLOUD_AUTH_MODE,
|
||||
DEFAULT_NEXTCLOUD_IMAGE_SIZE,
|
||||
NEXTCLOUD_IMAGE_PREVIEW,
|
||||
NEXTCLOUD_IMAGE_ORIGINAL,
|
||||
CONF_ENTE_URL,
|
||||
CONF_ENTE_ACCESS_TOKEN,
|
||||
CONF_ENTE_COLLECTION_KEY,
|
||||
CONF_ENTE_API_ORIGIN,
|
||||
CONF_ENTE_IMAGE_SIZE,
|
||||
DEFAULT_ENTE_IMAGE_SIZE,
|
||||
ENTE_IMAGE_FULL,
|
||||
ENTE_IMAGE_PREVIEW,
|
||||
DEFAULT_REVERSE_GEOCODE,
|
||||
PROVIDER_GOOGLE_SHARED,
|
||||
PROVIDER_LOCAL_FOLDER,
|
||||
@@ -90,6 +103,7 @@ from .const import (
|
||||
PROVIDER_ICLOUD,
|
||||
PROVIDER_SYNOLOGY,
|
||||
PROVIDER_NEXTCLOUD,
|
||||
PROVIDER_ENTE,
|
||||
DEFAULT_RECURSIVE,
|
||||
)
|
||||
|
||||
@@ -181,6 +195,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
if config_entry.data.get(CONF_PROVIDER) in (
|
||||
PROVIDER_LOCAL_FOLDER,
|
||||
PROVIDER_NEXTCLOUD,
|
||||
PROVIDER_ENTE,
|
||||
):
|
||||
return LocalFolderOptionsFlow()
|
||||
return _NoOptionsFlow()
|
||||
@@ -202,6 +217,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
return await self.async_step_synology()
|
||||
if self._provider == PROVIDER_NEXTCLOUD:
|
||||
return await self.async_step_nextcloud()
|
||||
if self._provider == PROVIDER_ENTE:
|
||||
return await self.async_step_ente()
|
||||
return await self.async_step_google_shared()
|
||||
|
||||
schema = vol.Schema(
|
||||
@@ -213,7 +230,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
PROVIDER_PHOTOPRISM: "PhotoPrism (direct API, full metadata)",
|
||||
PROVIDER_ICLOUD: "iCloud Shared Album",
|
||||
PROVIDER_SYNOLOGY: "Synology Photos (direct API, full metadata)",
|
||||
PROVIDER_NEXTCLOUD: "Nextcloud (WebDAV folder, full metadata)",
|
||||
PROVIDER_NEXTCLOUD: "Nextcloud (WebDAV folder or public album link)",
|
||||
PROVIDER_ENTE: "Ente Photos (public album link)",
|
||||
PROVIDER_MEDIA_SOURCE: "Media Source (any source, no metadata)",
|
||||
})
|
||||
}
|
||||
@@ -976,6 +994,29 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
|
||||
async def async_step_nextcloud(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Pick between an authenticated WebDAV folder and a public album link."""
|
||||
if user_input is not None:
|
||||
if user_input[CONF_NEXTCLOUD_AUTH_MODE] == NEXTCLOUD_AUTH_MODE_PUBLIC:
|
||||
return await self.async_step_nextcloud_public()
|
||||
return await self.async_step_nextcloud_folder()
|
||||
|
||||
schema = vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_NEXTCLOUD_AUTH_MODE, default=DEFAULT_NEXTCLOUD_AUTH_MODE
|
||||
): vol.In(
|
||||
{
|
||||
NEXTCLOUD_AUTH_MODE_FOLDER: "Authenticated WebDAV folder",
|
||||
NEXTCLOUD_AUTH_MODE_PUBLIC: "Public album link",
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
return self.async_show_form(step_id="nextcloud", data_schema=schema)
|
||||
|
||||
async def async_step_nextcloud_folder(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Collect and validate a Nextcloud WebDAV folder + app password."""
|
||||
errors: dict[str, str] = {}
|
||||
@@ -1010,6 +1051,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
title=name,
|
||||
data={
|
||||
CONF_PROVIDER: PROVIDER_NEXTCLOUD,
|
||||
CONF_NEXTCLOUD_AUTH_MODE: NEXTCLOUD_AUTH_MODE_FOLDER,
|
||||
CONF_NEXTCLOUD_URL: client.base_url,
|
||||
CONF_NEXTCLOUD_USERNAME: username,
|
||||
CONF_NEXTCLOUD_PASSWORD: password,
|
||||
@@ -1043,7 +1085,138 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
}
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="nextcloud", data_schema=schema, errors=errors
|
||||
step_id="nextcloud_folder", data_schema=schema, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_nextcloud_public(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Collect and validate a Nextcloud Photos public album link."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
name = user_input[CONF_ALBUM_NAME].strip()
|
||||
raw_url = user_input[CONF_NEXTCLOUD_URL].strip()
|
||||
size = user_input.get(
|
||||
CONF_NEXTCLOUD_IMAGE_SIZE, DEFAULT_NEXTCLOUD_IMAGE_SIZE
|
||||
)
|
||||
|
||||
from . import nextcloud as nc_api
|
||||
|
||||
parsed = nc_api.parse_share_link(raw_url)
|
||||
if parsed is None:
|
||||
errors[CONF_NEXTCLOUD_URL] = "invalid_nextcloud_url"
|
||||
else:
|
||||
base_url, token = parsed
|
||||
client = nc_api.NextcloudPublicClient(self.hass, base_url, token)
|
||||
try:
|
||||
await client.async_validate()
|
||||
except Exception: # noqa: BLE001 - any failure means bad/expired link
|
||||
errors["base"] = "nextcloud_public_cannot_connect"
|
||||
else:
|
||||
await self.async_set_unique_id(
|
||||
f"{DOMAIN}:{PROVIDER_NEXTCLOUD}:{base_url}:{token}:{name}"
|
||||
)
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=name,
|
||||
data={
|
||||
CONF_PROVIDER: PROVIDER_NEXTCLOUD,
|
||||
CONF_NEXTCLOUD_AUTH_MODE: NEXTCLOUD_AUTH_MODE_PUBLIC,
|
||||
CONF_NEXTCLOUD_URL: base_url,
|
||||
CONF_NEXTCLOUD_SHARE_TOKEN: token,
|
||||
CONF_NEXTCLOUD_IMAGE_SIZE: size,
|
||||
CONF_ALBUM_NAME: name,
|
||||
},
|
||||
)
|
||||
|
||||
schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ALBUM_NAME): str,
|
||||
vol.Required(CONF_NEXTCLOUD_URL): str,
|
||||
vol.Optional(
|
||||
CONF_NEXTCLOUD_IMAGE_SIZE, default=DEFAULT_NEXTCLOUD_IMAGE_SIZE
|
||||
): vol.In(
|
||||
{
|
||||
NEXTCLOUD_IMAGE_PREVIEW: "Preview (smoothest slideshow)",
|
||||
NEXTCLOUD_IMAGE_ORIGINAL: "Original (full quality, slower)",
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="nextcloud_public", data_schema=schema, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_ente(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Collect and validate an Ente Photos public album link."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
name = user_input[CONF_ALBUM_NAME].strip()
|
||||
raw_url = user_input[CONF_ENTE_URL].strip()
|
||||
api_origin = (user_input.get(CONF_ENTE_API_ORIGIN) or "").strip()
|
||||
size = user_input.get(CONF_ENTE_IMAGE_SIZE, DEFAULT_ENTE_IMAGE_SIZE)
|
||||
|
||||
from . import ente as ente_api
|
||||
|
||||
share = ente_api.parse_share_link(raw_url)
|
||||
if share is None:
|
||||
errors[CONF_ENTE_URL] = "invalid_ente_url"
|
||||
else:
|
||||
client = ente_api.EnteClient(self.hass, share, api_origin)
|
||||
try:
|
||||
await client.async_validate()
|
||||
except Exception as err: # noqa: BLE001 - bad, expired or private link
|
||||
_LOGGER.warning(
|
||||
"Ente validation failed for %s: %s",
|
||||
share.album_origin,
|
||||
_describe_error(err),
|
||||
)
|
||||
errors["base"] = "ente_cannot_connect"
|
||||
else:
|
||||
from base64 import b64encode
|
||||
|
||||
await self.async_set_unique_id(
|
||||
f"{DOMAIN}:{PROVIDER_ENTE}:{share.access_token}:{name}"
|
||||
)
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=name,
|
||||
data={
|
||||
CONF_PROVIDER: PROVIDER_ENTE,
|
||||
CONF_ENTE_URL: share.album_origin,
|
||||
CONF_ENTE_ACCESS_TOKEN: share.access_token,
|
||||
CONF_ENTE_COLLECTION_KEY: b64encode(
|
||||
share.collection_key
|
||||
).decode("ascii"),
|
||||
CONF_ENTE_API_ORIGIN: ente_api.normalize_api_origin(
|
||||
api_origin
|
||||
),
|
||||
CONF_ENTE_IMAGE_SIZE: size,
|
||||
CONF_ALBUM_NAME: name,
|
||||
},
|
||||
)
|
||||
|
||||
schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ALBUM_NAME): str,
|
||||
vol.Required(CONF_ENTE_URL): str,
|
||||
vol.Optional(
|
||||
CONF_ENTE_IMAGE_SIZE, default=DEFAULT_ENTE_IMAGE_SIZE
|
||||
): vol.In(
|
||||
{
|
||||
ENTE_IMAGE_FULL: "Full quality (original file)",
|
||||
ENTE_IMAGE_PREVIEW: "Preview (faster, softer)",
|
||||
}
|
||||
),
|
||||
vol.Optional(CONF_ENTE_API_ORIGIN): str,
|
||||
}
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="ente", data_schema=schema, errors=errors
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -59,18 +59,31 @@ PROVIDER_PHOTOPRISM = "photoprism"
|
||||
PROVIDER_ICLOUD = "icloud"
|
||||
PROVIDER_SYNOLOGY = "synology"
|
||||
PROVIDER_NEXTCLOUD = "nextcloud"
|
||||
PROVIDER_ENTE = "ente"
|
||||
|
||||
# Nextcloud provider - two auth modes against the same PROVIDER_NEXTCLOUD id:
|
||||
#
|
||||
# - "folder": authenticated WebDAV folder. Points at any folder in a user's
|
||||
# files and lists it over WebDAV. Auth is HTTP Basic with a username + app
|
||||
# password (Settings > Security > Devices & sessions); the app password is
|
||||
# stored so the coordinator can re-list on each refresh and is sent
|
||||
# server-side only, never reaching the browser.
|
||||
# - "public_link": a public Nextcloud Photos album share link. No credentials
|
||||
# involved - the share token embedded in the URL path is the only thing the
|
||||
# server checks against the unauthenticated ``photospublic`` WebDAV
|
||||
# collection.
|
||||
CONF_NEXTCLOUD_AUTH_MODE = "nextcloud_auth_mode"
|
||||
NEXTCLOUD_AUTH_MODE_FOLDER = "folder"
|
||||
NEXTCLOUD_AUTH_MODE_PUBLIC = "public_link"
|
||||
DEFAULT_NEXTCLOUD_AUTH_MODE = NEXTCLOUD_AUTH_MODE_FOLDER
|
||||
|
||||
# Nextcloud (authenticated WebDAV folder) provider. Points at any folder in a
|
||||
# user's files and lists it over WebDAV. Auth is HTTP Basic with a username +
|
||||
# app password (Settings > Security > Devices & sessions); the app password is
|
||||
# stored so the coordinator can re-list on each refresh and is sent server-side
|
||||
# only, never reaching the browser.
|
||||
CONF_NEXTCLOUD_URL = "nextcloud_url"
|
||||
CONF_NEXTCLOUD_USERNAME = "nextcloud_username"
|
||||
CONF_NEXTCLOUD_PASSWORD = "nextcloud_password"
|
||||
CONF_NEXTCLOUD_FOLDER = "nextcloud_folder"
|
||||
CONF_NEXTCLOUD_RECURSIVE = "nextcloud_recursive"
|
||||
CONF_NEXTCLOUD_IMAGE_SIZE = "nextcloud_image_size"
|
||||
CONF_NEXTCLOUD_SHARE_TOKEN = "nextcloud_share_token"
|
||||
|
||||
# ``preview`` uses the core/preview thumbnail endpoint (smoother, smaller);
|
||||
# ``original`` fetches the real file straight off the WebDAV collection.
|
||||
@@ -81,6 +94,25 @@ DEFAULT_NEXTCLOUD_IMAGE_SIZE = NEXTCLOUD_IMAGE_PREVIEW
|
||||
# Long edge (px) requested from the core/preview endpoint for preview quality.
|
||||
NEXTCLOUD_PREVIEW_PX = 1920
|
||||
|
||||
# Ente Photos (public album link) provider. Ente is end-to-end encrypted: the
|
||||
# access token in the link's ``?t=`` query authenticates to the API, while the
|
||||
# collection key lives in the link's URL *fragment* and never reaches the
|
||||
# server. Both are stored so the coordinator can re-list and decrypt on each
|
||||
# refresh; neither is ever put in an image URL, because Ente images are
|
||||
# decrypted inside Home Assistant and served from memory.
|
||||
CONF_ENTE_URL = "ente_url"
|
||||
CONF_ENTE_ACCESS_TOKEN = "ente_access_token"
|
||||
CONF_ENTE_COLLECTION_KEY = "ente_collection_key"
|
||||
CONF_ENTE_API_ORIGIN = "ente_api_origin"
|
||||
CONF_ENTE_IMAGE_SIZE = "ente_image_size"
|
||||
|
||||
# ``full`` downloads the original file; ``preview`` uses Ente's smaller
|
||||
# pre-generated thumbnail (much faster, noticeably softer on a big screen).
|
||||
ENTE_IMAGE_FULL = "full"
|
||||
ENTE_IMAGE_PREVIEW = "preview"
|
||||
ENTE_IMAGE_SIZE_OPTIONS = [ENTE_IMAGE_FULL, ENTE_IMAGE_PREVIEW]
|
||||
DEFAULT_ENTE_IMAGE_SIZE = ENTE_IMAGE_FULL
|
||||
|
||||
# Synology Photos (direct API) provider. Talks to a DSM Photos package over its
|
||||
# entry.cgi web API. The account password is stored so the coordinator can
|
||||
# re-authenticate when the session id expires; accounts with 2FA are handled by
|
||||
|
||||
@@ -65,9 +65,20 @@ from .const import (
|
||||
CONF_NEXTCLOUD_FOLDER,
|
||||
CONF_NEXTCLOUD_RECURSIVE,
|
||||
CONF_NEXTCLOUD_IMAGE_SIZE,
|
||||
CONF_NEXTCLOUD_SHARE_TOKEN,
|
||||
CONF_NEXTCLOUD_AUTH_MODE,
|
||||
NEXTCLOUD_AUTH_MODE_PUBLIC,
|
||||
DEFAULT_NEXTCLOUD_AUTH_MODE,
|
||||
DEFAULT_NEXTCLOUD_IMAGE_SIZE,
|
||||
NEXTCLOUD_IMAGE_ORIGINAL,
|
||||
NEXTCLOUD_PREVIEW_PX,
|
||||
CONF_ENTE_URL,
|
||||
CONF_ENTE_ACCESS_TOKEN,
|
||||
CONF_ENTE_COLLECTION_KEY,
|
||||
CONF_ENTE_API_ORIGIN,
|
||||
CONF_ENTE_IMAGE_SIZE,
|
||||
DEFAULT_ENTE_IMAGE_SIZE,
|
||||
ENTE_IMAGE_PREVIEW,
|
||||
DEFAULT_REVERSE_GEOCODE,
|
||||
DOMAIN,
|
||||
PROVIDER_GOOGLE_SHARED,
|
||||
@@ -78,6 +89,7 @@ from .const import (
|
||||
PROVIDER_ICLOUD,
|
||||
PROVIDER_SYNOLOGY,
|
||||
PROVIDER_NEXTCLOUD,
|
||||
PROVIDER_ENTE,
|
||||
)
|
||||
from .store import SlideshowStore
|
||||
|
||||
@@ -883,6 +895,55 @@ def _read_manifest_version(integration_dir: Path) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _build_ente_item(
|
||||
raw: dict[str, Any], collection_key: bytes, want_preview: bool
|
||||
) -> tuple[MediaItem, dict[str, Any]] | None:
|
||||
"""Decrypt one Ente file into a MediaItem plus its fetch material.
|
||||
|
||||
Runs in an executor: unsealing a key and opening two small secretstreams
|
||||
is CPU work, and an album can hold thousands of files. Returns ``None``
|
||||
for anything that is not a still image.
|
||||
"""
|
||||
from . import ente as ente_api
|
||||
|
||||
file_key = ente_api.decrypt_file_key(raw, collection_key)
|
||||
metadata = ente_api.decrypt_metadata(raw, file_key)
|
||||
if not ente_api.is_image(metadata):
|
||||
return None
|
||||
|
||||
magic = ente_api.decrypt_magic_metadata(raw, file_key)
|
||||
fields = ente_api.build_media_fields(metadata, magic)
|
||||
|
||||
attrs = raw.get("thumbnail" if want_preview else "file")
|
||||
if not isinstance(attrs, dict) or not attrs.get("decryptionHeader"):
|
||||
return None
|
||||
info = raw.get("info") if isinstance(raw.get("info"), dict) else {}
|
||||
|
||||
item = MediaItem(
|
||||
url=ente_api.build_item_url(raw["id"]),
|
||||
width=fields["width"],
|
||||
height=fields["height"],
|
||||
mime_type=None,
|
||||
filename=fields["filename"],
|
||||
captured_at=fields["captured_at"],
|
||||
uploaded_at=fields["uploaded_at"],
|
||||
byte_size=info.get("thumbSize" if want_preview else "fileSize"),
|
||||
latitude=fields["latitude"],
|
||||
longitude=fields["longitude"],
|
||||
description=fields["description"],
|
||||
source_id=str(raw["id"]),
|
||||
# Ente hands over full metadata up front, so there is nothing for the
|
||||
# per-photo enrichment download to add.
|
||||
exif_scanned=True,
|
||||
)
|
||||
meta = {
|
||||
"key": file_key,
|
||||
"header": attrs["decryptionHeader"],
|
||||
"thumbnail": want_preview,
|
||||
}
|
||||
return item, meta
|
||||
|
||||
|
||||
def _merge_prior_enrichment(
|
||||
new_items: list[MediaItem], prior_items: list[MediaItem]
|
||||
) -> None:
|
||||
@@ -938,6 +999,9 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
||||
# Extra headers the camera must send when fetching image bytes
|
||||
# (Immich API key). Empty for providers that need no auth.
|
||||
self.image_request_headers: dict[str, str] = {}
|
||||
# Ente only: file id -> {key, header, thumbnail}, the material needed
|
||||
# to decrypt each image. Rebuilt on every album refresh.
|
||||
self._ente_fetch_meta: dict[str, dict[str, Any]] = {}
|
||||
|
||||
# Persist the most recent successful album fetch so that a transient
|
||||
# network/Google failure doesn't blank the slideshow on restart.
|
||||
@@ -1011,6 +1075,8 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
||||
data = await self._update_synology()
|
||||
elif self.provider == PROVIDER_NEXTCLOUD:
|
||||
data = await self._update_nextcloud()
|
||||
elif self.provider == PROVIDER_ENTE:
|
||||
data = await self._update_ente()
|
||||
else:
|
||||
raise UpdateFailed(f"Unsupported provider: {self.provider}")
|
||||
except UpdateFailed:
|
||||
@@ -1024,7 +1090,12 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
||||
raise
|
||||
|
||||
items = data.get("items") or []
|
||||
if self.provider in (PROVIDER_LOCAL_FOLDER, PROVIDER_IMMICH, PROVIDER_NEXTCLOUD) and items:
|
||||
if self.provider in (
|
||||
PROVIDER_LOCAL_FOLDER,
|
||||
PROVIDER_IMMICH,
|
||||
PROVIDER_NEXTCLOUD,
|
||||
PROVIDER_ENTE,
|
||||
) and items:
|
||||
# Carry forward EXIF/geocode metadata for items we've already
|
||||
# scanned this session; new items get filled in by the
|
||||
# background worker below.
|
||||
@@ -1066,7 +1137,13 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
||||
"""Kick off the background EXIF + geocode worker if there's work."""
|
||||
items: list[MediaItem] = data.get("items") or []
|
||||
unscanned = [it for it in items if not it.exif_scanned]
|
||||
if not unscanned:
|
||||
# Providers that decrypt/return GPS inline (Ente) have nothing to scan
|
||||
# but still need the coordinates turned into a place label.
|
||||
needs_geocode = any(
|
||||
it.latitude is not None and it.longitude is not None and not it.location
|
||||
for it in items
|
||||
)
|
||||
if not unscanned and not needs_geocode:
|
||||
# Even with nothing to do, mark the phase as ``done`` so the
|
||||
# diagnostic sensor stops reporting an in-progress run from
|
||||
# the previous refresh.
|
||||
@@ -1736,6 +1813,118 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
||||
}
|
||||
|
||||
async def _update_nextcloud(self) -> dict[str, Any]:
|
||||
"""List photos from Nextcloud, dispatching on the configured auth mode."""
|
||||
mode = self.entry.data.get(
|
||||
CONF_NEXTCLOUD_AUTH_MODE, DEFAULT_NEXTCLOUD_AUTH_MODE
|
||||
)
|
||||
if mode == NEXTCLOUD_AUTH_MODE_PUBLIC:
|
||||
return await self._update_nextcloud_public()
|
||||
return await self._update_nextcloud_folder()
|
||||
|
||||
def _ente_share(self):
|
||||
"""Rebuild the parsed share (token + collection key) from entry data."""
|
||||
from base64 import b64decode
|
||||
|
||||
from . import ente as ente_api
|
||||
|
||||
token = self.entry.data.get(CONF_ENTE_ACCESS_TOKEN)
|
||||
raw_key = self.entry.data.get(CONF_ENTE_COLLECTION_KEY)
|
||||
if not token or not raw_key:
|
||||
raise UpdateFailed("Ente entry is missing its link credentials")
|
||||
return ente_api.EnteShare(
|
||||
token,
|
||||
b64decode(raw_key),
|
||||
self.entry.data.get(CONF_ENTE_URL) or "",
|
||||
)
|
||||
|
||||
async def _update_ente(self) -> dict[str, Any]:
|
||||
"""List and decrypt an Ente public album.
|
||||
|
||||
Ente returns everything encrypted, so each file's key is unsealed from
|
||||
the collection key and its metadata decrypted here. That metadata is
|
||||
complete (capture date, GPS, caption), so unlike the folder-style
|
||||
providers there is no per-photo enrichment download; items come back
|
||||
already scanned and only need reverse-geocoding.
|
||||
"""
|
||||
from . import ente as ente_api
|
||||
|
||||
share = self._ente_share()
|
||||
client = ente_api.EnteClient(
|
||||
self.hass, share, self.entry.data.get(CONF_ENTE_API_ORIGIN)
|
||||
)
|
||||
|
||||
try:
|
||||
files = await client.async_list_files()
|
||||
except Exception as err:
|
||||
raise UpdateFailed(f"Error listing Ente album: {err}") from err
|
||||
|
||||
want_preview = (
|
||||
self.entry.data.get(CONF_ENTE_IMAGE_SIZE, DEFAULT_ENTE_IMAGE_SIZE)
|
||||
== ENTE_IMAGE_PREVIEW
|
||||
)
|
||||
|
||||
items: list[MediaItem] = []
|
||||
fetch_meta: dict[str, dict[str, Any]] = {}
|
||||
for f in files:
|
||||
try:
|
||||
built = await self.hass.async_add_executor_job(
|
||||
_build_ente_item, f, share.collection_key, want_preview
|
||||
)
|
||||
except Exception as err: # noqa: BLE001 - one bad file must not fail the album
|
||||
_LOGGER.debug("Ente: skipping file %s (%s)", f.get("id"), err)
|
||||
continue
|
||||
if built is None:
|
||||
continue
|
||||
item, meta = built
|
||||
items.append(item)
|
||||
fetch_meta[str(f["id"])] = meta
|
||||
|
||||
if not items:
|
||||
raise UpdateFailed("No images found in the Ente album")
|
||||
|
||||
# Per-file keys + stream headers, so the camera can decrypt on demand
|
||||
# without re-listing the album for every slide.
|
||||
self._ente_fetch_meta = fetch_meta
|
||||
|
||||
return {
|
||||
"title": self.entry.title,
|
||||
"items": items,
|
||||
}
|
||||
|
||||
async def async_fetch_image_bytes(self, url: str) -> bytes | None:
|
||||
"""Fetch and decrypt bytes for a provider-internal URL.
|
||||
|
||||
Only Ente uses this today: its images cannot be served by URL because
|
||||
the bytes are end-to-end encrypted, so the camera hands the synthetic
|
||||
``ente://<fileID>`` id back here to be downloaded and decrypted.
|
||||
Returns ``None`` for anything this coordinator does not own.
|
||||
"""
|
||||
from . import ente as ente_api
|
||||
|
||||
if self.provider != PROVIDER_ENTE:
|
||||
return None
|
||||
file_id = ente_api.file_id_from_url(url)
|
||||
if not file_id:
|
||||
return None
|
||||
|
||||
meta = (self._ente_fetch_meta or {}).get(str(file_id))
|
||||
if not meta:
|
||||
_LOGGER.debug("Ente: no decryption material cached for %s", file_id)
|
||||
return None
|
||||
|
||||
share = self._ente_share()
|
||||
client = ente_api.EnteClient(
|
||||
self.hass, share, self.entry.data.get(CONF_ENTE_API_ORIGIN)
|
||||
)
|
||||
try:
|
||||
return await client.async_fetch_image(
|
||||
file_id, meta["key"], meta["header"], meta["thumbnail"]
|
||||
)
|
||||
except Exception as err: # noqa: BLE001
|
||||
_LOGGER.warning("Ente: failed to fetch image %s: %s", file_id, err)
|
||||
return None
|
||||
|
||||
async def _update_nextcloud_folder(self) -> dict[str, Any]:
|
||||
"""List photos from an authenticated Nextcloud WebDAV folder.
|
||||
|
||||
The PROPFIND listing carries filename/size/content-type/mtime but no
|
||||
@@ -1799,39 +1988,85 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
||||
"items": items,
|
||||
}
|
||||
|
||||
async def _update_nextcloud_public(self) -> dict[str, Any]:
|
||||
"""List photos from a Nextcloud Photos public collaborative album.
|
||||
|
||||
The PROPFIND listing carries filename/size/content-type/mtime but no
|
||||
EXIF, so capture date, GPS and description are filled in afterwards
|
||||
by the background enrichment worker (one original-file download per
|
||||
photo - Nextcloud has no metadata-only endpoint the way Immich does).
|
||||
No credentials are involved - the share token is embedded in the URL.
|
||||
"""
|
||||
from . import nextcloud as nc_api
|
||||
|
||||
url = self.entry.data.get(CONF_NEXTCLOUD_URL)
|
||||
token = self.entry.data.get(CONF_NEXTCLOUD_SHARE_TOKEN)
|
||||
size = self.entry.data.get(
|
||||
CONF_NEXTCLOUD_IMAGE_SIZE, DEFAULT_NEXTCLOUD_IMAGE_SIZE
|
||||
)
|
||||
if not url or not token:
|
||||
raise UpdateFailed("Nextcloud provider is missing the URL or share token")
|
||||
|
||||
client = nc_api.NextcloudPublicClient(self.hass, url, token)
|
||||
try:
|
||||
photos = await client.async_list_photos()
|
||||
except Exception as err:
|
||||
raise UpdateFailed(f"Error listing Nextcloud album: {err}") from err
|
||||
|
||||
if not photos:
|
||||
raise UpdateFailed("No images found in the Nextcloud album")
|
||||
|
||||
items: list[MediaItem] = []
|
||||
for p in photos:
|
||||
href = p.get("href")
|
||||
if not href:
|
||||
continue
|
||||
if size != NEXTCLOUD_IMAGE_ORIGINAL and p.get("file_id"):
|
||||
display_url = nc_api.build_preview_url_public(
|
||||
client.base_url, token, p["file_id"], NEXTCLOUD_PREVIEW_PX
|
||||
)
|
||||
else:
|
||||
display_url = href
|
||||
items.append(
|
||||
MediaItem(
|
||||
url=display_url,
|
||||
width=None,
|
||||
height=None,
|
||||
mime_type=p.get("content_type"),
|
||||
filename=p.get("display_name") or p.get("filename"),
|
||||
uploaded_at=p.get("mtime_ms"),
|
||||
byte_size=p.get("size"),
|
||||
# The DAV href, so enrichment can fetch the original bytes
|
||||
# even when the display URL is a preview.
|
||||
source_id=href,
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"title": self.entry.title,
|
||||
"items": items,
|
||||
}
|
||||
|
||||
async def _enrich_nextcloud_item(self, item: MediaItem) -> None:
|
||||
"""Download one Nextcloud photo's original bytes and read its EXIF.
|
||||
|
||||
Nextcloud's WebDAV folder has no metadata-only endpoint (unlike
|
||||
Nextcloud has no metadata-only endpoint for either auth mode (unlike
|
||||
Immich's per-asset detail call), so enrichment costs one full-file
|
||||
download per photo regardless of the display quality configured.
|
||||
Dispatches on the configured auth mode to build the right download
|
||||
URL/headers; the download-and-parse tail is identical either way.
|
||||
"""
|
||||
from . import nextcloud as nc_api
|
||||
from urllib.parse import quote
|
||||
|
||||
url = self.entry.data.get(CONF_NEXTCLOUD_URL)
|
||||
username = self.entry.data.get(CONF_NEXTCLOUD_USERNAME)
|
||||
password = self.entry.data.get(CONF_NEXTCLOUD_PASSWORD)
|
||||
folder = self.entry.data.get(CONF_NEXTCLOUD_FOLDER) or ""
|
||||
if not username or not password or not url:
|
||||
item.exif_scanned = True
|
||||
return
|
||||
|
||||
# Reconstruct the original-file URL: for preview items the display url
|
||||
# is the preview endpoint, so fall back to the folder href by filename.
|
||||
original_url = None
|
||||
if isinstance(item.url, str) and "/remote.php/dav/files/" in item.url:
|
||||
original_url = item.url
|
||||
elif item.filename:
|
||||
client = nc_api.NextcloudClient(self.hass, url, username, password, folder)
|
||||
original_url = client.dav_root + quote(item.filename)
|
||||
mode = self.entry.data.get(
|
||||
CONF_NEXTCLOUD_AUTH_MODE, DEFAULT_NEXTCLOUD_AUTH_MODE
|
||||
)
|
||||
if mode == NEXTCLOUD_AUTH_MODE_PUBLIC:
|
||||
original_url, headers = self._nextcloud_public_original(item)
|
||||
else:
|
||||
original_url, headers = self._nextcloud_folder_original(item)
|
||||
if not original_url:
|
||||
item.exif_scanned = True
|
||||
return
|
||||
|
||||
headers = {
|
||||
"Authorization": nc_api.basic_auth_header(username, password)
|
||||
}
|
||||
session = async_get_clientsession(self.hass)
|
||||
try:
|
||||
async with async_timeout.timeout(30):
|
||||
@@ -1872,6 +2107,50 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
||||
item.longitude = info["longitude"]
|
||||
item.exif_scanned = True
|
||||
|
||||
def _nextcloud_folder_original(
|
||||
self, item: MediaItem
|
||||
) -> tuple[str | None, dict[str, str]]:
|
||||
"""Return ``(original_url, headers)`` for a folder-mode item, or (None, {})."""
|
||||
from . import nextcloud as nc_api
|
||||
from urllib.parse import quote
|
||||
|
||||
url = self.entry.data.get(CONF_NEXTCLOUD_URL)
|
||||
username = self.entry.data.get(CONF_NEXTCLOUD_USERNAME)
|
||||
password = self.entry.data.get(CONF_NEXTCLOUD_PASSWORD)
|
||||
folder = self.entry.data.get(CONF_NEXTCLOUD_FOLDER) or ""
|
||||
if not username or not password or not url:
|
||||
return None, {}
|
||||
|
||||
# Reconstruct the original-file URL: for preview items the display url
|
||||
# is the preview endpoint, so fall back to the folder href by filename.
|
||||
original_url = None
|
||||
if isinstance(item.url, str) and "/remote.php/dav/files/" in item.url:
|
||||
original_url = item.url
|
||||
elif item.filename:
|
||||
client = nc_api.NextcloudClient(self.hass, url, username, password, folder)
|
||||
original_url = client.dav_root + quote(item.filename)
|
||||
if not original_url:
|
||||
return None, {}
|
||||
return original_url, {"Authorization": nc_api.basic_auth_header(username, password)}
|
||||
|
||||
def _nextcloud_public_original(
|
||||
self, item: MediaItem
|
||||
) -> tuple[str | None, dict[str, str]]:
|
||||
"""Return ``(original_url, headers)`` for a public-link item, or (None, {})."""
|
||||
url = self.entry.data.get(CONF_NEXTCLOUD_URL)
|
||||
token = self.entry.data.get(CONF_NEXTCLOUD_SHARE_TOKEN)
|
||||
if not url or not token:
|
||||
return None, {}
|
||||
|
||||
# For preview items the display url is the preview endpoint, so fall
|
||||
# back to the DAV href stashed on the item at listing time.
|
||||
for candidate in (item.url, item.source_id):
|
||||
if isinstance(candidate, str) and (
|
||||
"/remote.php/dav/photospublic/" in candidate
|
||||
):
|
||||
return candidate, {}
|
||||
return None, {}
|
||||
|
||||
async def _enrich_immich_item(self, item: MediaItem) -> None:
|
||||
"""Fetch one Immich asset's detail and fill location/description."""
|
||||
from . import immich as immich_api
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"documentation": "https://github.com/eyalgal/album_slideshow",
|
||||
"iot_class": "cloud_polling",
|
||||
"issue_tracker": "https://github.com/eyalgal/album_slideshow/issues",
|
||||
"requirements": ["Pillow"],
|
||||
"version": "1.8.1"
|
||||
"requirements": ["Pillow", "PyNaCl>=1.5.0"],
|
||||
"version": "1.9.0"
|
||||
}
|
||||
|
||||
@@ -111,6 +111,13 @@
|
||||
}
|
||||
},
|
||||
"nextcloud": {
|
||||
"title": "Nextcloud",
|
||||
"description": "Choose how to connect: an authenticated WebDAV folder in your Nextcloud files, or a public Nextcloud Photos album share link.",
|
||||
"data": {
|
||||
"nextcloud_auth_mode": "Connection type"
|
||||
}
|
||||
},
|
||||
"nextcloud_folder": {
|
||||
"title": "Nextcloud folder",
|
||||
"description": "Connect to a folder in your Nextcloud files over WebDAV. Enter the server address (e.g. http://192.168.1.10, or your Nextcloud domain over HTTPS), your username, and an app password (create one under Settings > Security > Devices and sessions - not your main login password). Point it at a folder path (e.g. Photos/Family), or leave the folder blank for your whole files root. Turn on recursive to include subfolders.",
|
||||
"data": {
|
||||
@@ -122,6 +129,25 @@
|
||||
"nextcloud_recursive": "Include subfolders",
|
||||
"nextcloud_image_size": "Image quality"
|
||||
}
|
||||
},
|
||||
"ente": {
|
||||
"title": "Ente Photos public album",
|
||||
"description": "Paste a public Ente album link, for example https://albums.ente.io/?t=TOKEN#KEY. Copy the whole link including the part after the # - that fragment is the decryption key and the album cannot be read without it. No Ente account or password is needed. Leave the API endpoint blank unless you self-host Ente.",
|
||||
"data": {
|
||||
"album_name": "Album name",
|
||||
"ente_url": "Album share link",
|
||||
"ente_image_size": "Image quality",
|
||||
"ente_api_origin": "API endpoint (self-hosted only)"
|
||||
}
|
||||
},
|
||||
"nextcloud_public": {
|
||||
"title": "Nextcloud public album link",
|
||||
"description": "Paste a public Nextcloud Photos album share link (from the Photos app's Share menu). No login is needed - the link itself grants access.",
|
||||
"data": {
|
||||
"album_name": "Album name",
|
||||
"nextcloud_url": "Album share link",
|
||||
"nextcloud_image_size": "Image quality"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -143,7 +169,11 @@
|
||||
"synology_cannot_connect": "Could not connect to Synology. Check the URL, username and password.",
|
||||
"synology_otp_required": "This account needs a two-factor code. Enter a current 6-digit code to continue.",
|
||||
"synology_shared_unavailable": "Could not access the Shared Space. Enable Shared Space in Synology Photos and make sure this account has access to it, or choose Personal instead.",
|
||||
"nextcloud_cannot_connect": "Could not connect to Nextcloud. Check the URL, username, app password and folder path."
|
||||
"nextcloud_cannot_connect": "Could not connect to Nextcloud. Check the URL, username, app password and folder path.",
|
||||
"invalid_nextcloud_url": "That does not look like a Nextcloud Photos public album link.",
|
||||
"nextcloud_public_cannot_connect": "Could not connect to that Nextcloud album. Check the link is still valid.",
|
||||
"invalid_ente_url": "That does not look like an Ente public album link. It should look like https://albums.ente.io/?t=TOKEN#KEY, including the part after the #."
|
||||
,"ente_cannot_connect": "Could not open that Ente album. The link may be expired, revoked, or password-protected."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
|
||||
@@ -111,6 +111,13 @@
|
||||
}
|
||||
},
|
||||
"nextcloud": {
|
||||
"title": "Nextcloud",
|
||||
"description": "Choose how to connect: an authenticated WebDAV folder in your Nextcloud files, or a public Nextcloud Photos album share link.",
|
||||
"data": {
|
||||
"nextcloud_auth_mode": "Connection type"
|
||||
}
|
||||
},
|
||||
"nextcloud_folder": {
|
||||
"title": "Nextcloud folder",
|
||||
"description": "Connect to a folder in your Nextcloud files over WebDAV. Enter the server address (e.g. http://192.168.1.10, or your Nextcloud domain over HTTPS), your username, and an app password (create one under Settings > Security > Devices and sessions - not your main login password). Point it at a folder path (e.g. Photos/Family), or leave the folder blank for your whole files root. Turn on recursive to include subfolders.",
|
||||
"data": {
|
||||
@@ -122,6 +129,25 @@
|
||||
"nextcloud_recursive": "Include subfolders",
|
||||
"nextcloud_image_size": "Image quality"
|
||||
}
|
||||
},
|
||||
"ente": {
|
||||
"title": "Ente Photos public album",
|
||||
"description": "Paste a public Ente album link, for example https://albums.ente.io/?t=TOKEN#KEY. Copy the whole link including the part after the # - that fragment is the decryption key and the album cannot be read without it. No Ente account or password is needed. Leave the API endpoint blank unless you self-host Ente.",
|
||||
"data": {
|
||||
"album_name": "Album name",
|
||||
"ente_url": "Album share link",
|
||||
"ente_image_size": "Image quality",
|
||||
"ente_api_origin": "API endpoint (self-hosted only)"
|
||||
}
|
||||
},
|
||||
"nextcloud_public": {
|
||||
"title": "Nextcloud public album link",
|
||||
"description": "Paste a public Nextcloud Photos album share link (from the Photos app's Share menu). No login is needed - the link itself grants access.",
|
||||
"data": {
|
||||
"album_name": "Album name",
|
||||
"nextcloud_url": "Album share link",
|
||||
"nextcloud_image_size": "Image quality"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -143,7 +169,11 @@
|
||||
"synology_cannot_connect": "Could not connect to Synology. Check the URL, username and password.",
|
||||
"synology_otp_required": "This account needs a two-factor code. Enter a current 6-digit code to continue.",
|
||||
"synology_shared_unavailable": "Could not access the Shared Space. Enable Shared Space in Synology Photos and make sure this account has access to it, or choose Personal instead.",
|
||||
"nextcloud_cannot_connect": "Could not connect to Nextcloud. Check the URL, username, app password and folder path."
|
||||
"nextcloud_cannot_connect": "Could not connect to Nextcloud. Check the URL, username, app password and folder path.",
|
||||
"invalid_nextcloud_url": "That does not look like a Nextcloud Photos public album link.",
|
||||
"nextcloud_public_cannot_connect": "Could not connect to that Nextcloud album. Check the link is still valid.",
|
||||
"invalid_ente_url": "That does not look like an Ente public album link. It should look like https://albums.ente.io/?t=TOKEN#KEY, including the part after the #."
|
||||
,"ente_cannot_connect": "Could not open that Ente album. The link may be expired, revoked, or password-protected."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
* tap_action: none # none | more-info
|
||||
*/
|
||||
|
||||
const VERSION = "1.8.1";
|
||||
const VERSION = "1.9.0";
|
||||
|
||||
const ANIMATED_TRANSITIONS = [
|
||||
"fade",
|
||||
@@ -810,9 +810,11 @@ function createAlbumSlideshowCardClass(Base) {
|
||||
ddd: part({ weekday: "short" }),
|
||||
HH: pad(d.getHours()),
|
||||
mm: pad(d.getMinutes()),
|
||||
// Lets relative time be mixed into a format string, e.g. "DD MMMM YYYY - REL".
|
||||
REL: this._relativeTime(d),
|
||||
};
|
||||
return fmt.replace(
|
||||
/YYYY|YY|MMMM|MMM|MM|M|DD|D|dddd|ddd|HH|mm/g,
|
||||
/REL|YYYY|YY|MMMM|MMM|MM|M|DD|D|dddd|ddd|HH|mm/g,
|
||||
(t) => map[t],
|
||||
);
|
||||
}
|
||||
@@ -925,6 +927,10 @@ const CAPTION_DATE_FORMAT_OPTIONS = [
|
||||
{ value: "numeric", label: "Numeric (8/16/2014)" },
|
||||
{ value: "weekday", label: "Weekday (Saturday, August 16, 2014)" },
|
||||
{ value: "relative", label: "Relative (3 years ago)" },
|
||||
{
|
||||
value: "D MMMM YYYY - REL",
|
||||
label: "Date + relative (16 August 2014 - 3 years ago)",
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULTS = {
|
||||
@@ -1440,7 +1446,7 @@ function createAlbumSlideshowCardEditorClass(Base) {
|
||||
tap_pause_seconds:
|
||||
"How long the card freezes its slide after a tap. 0 disables it.",
|
||||
caption_date_format:
|
||||
"Pick a preset or type a custom format (YYYY, MMMM, MMM, MM, DD, D).",
|
||||
"Pick a preset or type a custom format (YYYY, MMMM, MMM, MM, DD, D, REL for relative time).",
|
||||
caption_show:
|
||||
"Description comes from the photo's EXIF/IPTC/XMP caption and is only available with the local-folder provider.",
|
||||
caption_per_image:
|
||||
|
||||
@@ -1147,7 +1147,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
rotation tasks that were configured without an initial assignee — those
|
||||
were invisible to every user filter until their first completion.
|
||||
"""
|
||||
if entry.version > 1 or entry.minor_version >= 5:
|
||||
if entry.version > 1 or entry.minor_version >= 6:
|
||||
return True
|
||||
|
||||
is_object_entry = entry.unique_id != GLOBAL_UNIQUE_ID
|
||||
@@ -1225,7 +1225,28 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
shed_owned_devices(hass, own_entry_id=entry.entry_id, source_device_id=device_id)
|
||||
minor = 5
|
||||
|
||||
hass.config_entries.async_update_entry(entry, data=data, minor_version=minor)
|
||||
# 5 → 6 (#145): task rows show Complete / Skip as HA buttons instead of
|
||||
# bare icons. An EXISTING install gets a one-time panel notice with a
|
||||
# "back to icons" button (the flag lives in the hub entry's options and
|
||||
# is cleared by that banner); a fresh install starts at minor 6 and
|
||||
# never sees it.
|
||||
new_options: dict[str, Any] | None = None
|
||||
if minor < 6:
|
||||
if not is_object_entry:
|
||||
from .const import CONF_ROW_ACTION_NOTICE
|
||||
|
||||
# Older installs keep their settings in entry.data with EMPTY
|
||||
# options (every reader uses `entry.options or entry.data`); the
|
||||
# first options write must carry them over, or the flag alone
|
||||
# would mask every setting.
|
||||
base = dict(entry.options) if entry.options else dict(entry.data)
|
||||
new_options = {**base, CONF_ROW_ACTION_NOTICE: True}
|
||||
minor = 6
|
||||
|
||||
if new_options is not None:
|
||||
hass.config_entries.async_update_entry(entry, data=data, options=new_options, minor_version=minor)
|
||||
else:
|
||||
hass.config_entries.async_update_entry(entry, data=data, minor_version=minor)
|
||||
_LOGGER.info("Migrated entry %s to minor_version %s", entry.entry_id, minor)
|
||||
return True
|
||||
|
||||
@@ -1445,6 +1466,33 @@ async def async_setup_entry(hass: HomeAssistant, entry: MaintenanceSupporterConf
|
||||
entry.runtime_data = MaintenanceSupporterData(coordinator=coordinator, store=store)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
# #148: a battery type that becomes known only after fleet setup (a
|
||||
# Battery Notes note added later, a typed low-only Matter binary)
|
||||
# never got its spare part — reconcile once per start, after the
|
||||
# other integrations' entities exist. Also prunes the legacy
|
||||
# untouched "UNKNOWN battery" part older versions minted. Registered
|
||||
# AFTER runtime_data: on a running instance async_at_started fires
|
||||
# immediately, and the reconcile needs the store for stock/prune.
|
||||
if obj_data.get(BATTERY_FLEET_OBJECT_FLAG):
|
||||
|
||||
async def _fleet_parts_at_start(_hass: HomeAssistant) -> None:
|
||||
from .helpers.battery_fleet_setup import reconcile_fleet_parts_at_start
|
||||
from .helpers.i18n import normalize_language
|
||||
|
||||
try:
|
||||
result = await reconcile_fleet_parts_at_start(hass, entry, normalize_language(hass))
|
||||
except Exception:
|
||||
_LOGGER.exception("Battery-fleet part reconcile at start failed")
|
||||
return
|
||||
if result["added"] or result["pruned"]:
|
||||
_LOGGER.info(
|
||||
"Battery-fleet parts reconciled at start: added %s, pruned legacy UNKNOWN: %s",
|
||||
result["added"] or "none",
|
||||
result["pruned"],
|
||||
)
|
||||
|
||||
entry.async_on_unload(async_at_started(hass, _fleet_parts_at_start))
|
||||
|
||||
# v1.5.3 (#48): forward-sync obj fields → device_registry whenever the
|
||||
# entry data changes (WS update OR config-flow re-edit). DeviceInfo
|
||||
# only seeds these on first device creation; after that, dashboard
|
||||
|
||||
@@ -63,7 +63,7 @@ class MaintenanceSupporterConfigFlow(ScheduleStepsMixin, TriggerConfigMixin, Con
|
||||
"""Handle a config flow for Maintenance Supporter."""
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 5
|
||||
MINOR_VERSION = 6
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
|
||||
@@ -122,6 +122,23 @@ CONF_PANEL_ENABLED = "panel_enabled"
|
||||
# still honoured. Read this default everywhere instead of inlining the literal.
|
||||
DEFAULT_PANEL_ENABLED = True
|
||||
CONF_PANEL_TITLE = "panel_title"
|
||||
# #145: how task rows show Complete / Skip — HA buttons that collapse to
|
||||
# icon-only on phones (default), labelled buttons everywhere, or the classic
|
||||
# icon pair. The notice flag is set ONCE by the 5→6 migration for existing
|
||||
# installs so the panel can announce the new look with a "back to icons" button.
|
||||
CONF_ROW_ACTION_STYLE = "row_action_style"
|
||||
CONF_ROW_ACTION_NOTICE = "row_action_notice_pending"
|
||||
ROW_ACTION_STYLES: tuple[str, ...] = ("buttons_compact", "buttons", "icons")
|
||||
DEFAULT_ROW_ACTION_STYLE = "buttons_compact"
|
||||
# #146: household defaults for the "low" thresholds that discovery and the
|
||||
# battery fleet used to hard-code. Consumables: the catalog floor for
|
||||
# percent-remaining signatures (Suggested setups). Batteries: the fleet-wide
|
||||
# floor for batteries without their own Battery Notes threshold.
|
||||
CONF_DEFAULT_CONSUMABLE_THRESHOLD = "default_consumable_threshold"
|
||||
CONF_BATTERY_LOW_PERCENT = "battery_low_percent"
|
||||
DEFAULT_CONSUMABLE_THRESHOLD = 10
|
||||
DEFAULT_BATTERY_LOW_PERCENT = 20
|
||||
THRESHOLD_PERCENT_RANGE: tuple[int, int] = (1, 90)
|
||||
|
||||
# --- Config Keys: Advanced Feature Visibility ---
|
||||
CONF_ADVANCED_ADAPTIVE = "advanced_adaptive_visible"
|
||||
|
||||
@@ -43,6 +43,19 @@ interface SettingsResponse {
|
||||
panel_title: string;
|
||||
/** Opt-in copy of the shipped Assist sentences into the config dir. */
|
||||
install_assist_sentences?: boolean;
|
||||
/** #146: household "low" floors (percent) for discovery + battery fleet. */
|
||||
default_consumable_threshold?: number;
|
||||
battery_low_percent?: number;
|
||||
/** Computed: what Battery Notes reports (null when not installed). */
|
||||
battery_notes?: {
|
||||
default: number;
|
||||
devices: number;
|
||||
overrides: { name: string; device_id: string | null; threshold: number }[];
|
||||
more: number;
|
||||
} | null;
|
||||
/** #145: how task rows show Complete / Skip. */
|
||||
row_action_style?: "buttons_compact" | "buttons" | "icons";
|
||||
row_action_notice_pending?: boolean;
|
||||
};
|
||||
notifications: {
|
||||
due_soon_enabled: boolean;
|
||||
@@ -241,6 +254,37 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
this._vacWindowEnd = v.window_end;
|
||||
}
|
||||
|
||||
/** #146 follow-up: what Battery Notes currently reports, with the rule
|
||||
* spelled out — warn-toned when its default sits above our floor (then a
|
||||
* lowered floor would silently change nothing for noted batteries). */
|
||||
private _renderBatteryNotesHint(L: string) {
|
||||
const bn = this._settings?.general?.battery_notes;
|
||||
if (!bn || !bn.devices) return nothing;
|
||||
const floor = this._settings?.general?.battery_low_percent ?? 20;
|
||||
const warn = bn.default > floor;
|
||||
const summary = t("bn_summary", L)
|
||||
.replace("{name}", "Battery Notes")
|
||||
.replace("{pct}", String(bn.default))
|
||||
.replace("{n}", String(bn.devices));
|
||||
const rule = (warn ? t("bn_above_floor", L).replace("{name}", "Battery Notes") : t("bn_floor_decides", L))
|
||||
.replace("{floor}", String(floor));
|
||||
return html`
|
||||
<div class="bn-note${warn ? " warn" : ""}">
|
||||
<ha-icon icon="${warn ? "mdi:alert-outline" : "mdi:battery-heart-variant"}"></ha-icon>
|
||||
<span>
|
||||
<a class="bn-link" href="/config/integrations/integration/battery_notes">${summary.split(":")[0]}</a>${summary.slice(summary.indexOf(":"))}
|
||||
${bn.overrides.length
|
||||
? html` · ${t("bn_overrides", L).replace("{n}", String(bn.overrides.length + bn.more))}
|
||||
${bn.overrides.map((o, i) => html`${i ? " · " : " "}${o.device_id
|
||||
? html`<a class="bn-link" href="/config/devices/device/${o.device_id}">${o.name}</a>`
|
||||
: o.name} (${o.threshold} %)`)}
|
||||
${bn.more ? html` <span class="bn-more">${t("bn_more", L).replace("{n}", String(bn.more))}</span>` : nothing}`
|
||||
: nothing}
|
||||
<span class="bn-sub">${rule}</span>
|
||||
</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private async _updateSetting(key: string, value: unknown): Promise<void> {
|
||||
try {
|
||||
const result = await this.hass.connection.sendMessagePromise({
|
||||
@@ -594,6 +638,32 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
if (v >= 0 && v <= 365) this._updateSetting("default_warning_days", v);
|
||||
}} />
|
||||
</label>
|
||||
<label class="setting-row">
|
||||
<span class="setting-label">${t("settings_consumable_threshold", L)}</span>
|
||||
<input type="number" min="1" max="90" .value=${String(g.default_consumable_threshold ?? 10)}
|
||||
@change=${(e: Event) => {
|
||||
const v = parseInt((e.target as HTMLInputElement).value, 10);
|
||||
if (v >= 1 && v <= 90) this._updateSetting("default_consumable_threshold", v);
|
||||
}} />
|
||||
</label>
|
||||
<label class="setting-row">
|
||||
<span class="setting-label">${t("settings_battery_low_percent", L)}</span>
|
||||
<input type="number" min="1" max="90" .value=${String(g.battery_low_percent ?? 20)}
|
||||
@change=${(e: Event) => {
|
||||
const v = parseInt((e.target as HTMLInputElement).value, 10);
|
||||
if (v >= 1 && v <= 90) this._updateSetting("battery_low_percent", v);
|
||||
}} />
|
||||
</label>
|
||||
${this._renderBatteryNotesHint(L)}
|
||||
<div class="setting-hint">${t("settings_thresholds_hint", L)}</div>
|
||||
<label class="setting-row">
|
||||
<span class="setting-label">${t("settings_row_actions", L)}</span>
|
||||
<select .value=${live(g.row_action_style || "buttons_compact")}
|
||||
@change=${(e: Event) => this._updateSetting("row_action_style", (e.target as HTMLSelectElement).value)}>
|
||||
${(["buttons_compact", "buttons", "icons"] as const).map((v) => html`
|
||||
<option value=${v} ?selected=${(g.row_action_style || "buttons_compact") === v}>${t(`row_actions_${v}`, L)}</option>`)}
|
||||
</select>
|
||||
</label>
|
||||
<label class="setting-row">
|
||||
<span class="setting-label">${t("settings_currency", L)}</span>
|
||||
<select .value=${live(b.currency)} @change=${(e: Event) => this._updateSetting("budget_currency", (e.target as HTMLSelectElement).value)}>
|
||||
@@ -1667,6 +1737,21 @@ export class MaintenanceSettingsView extends LitElement {
|
||||
// --- Styles ---
|
||||
|
||||
static styles = css`
|
||||
.bn-note {
|
||||
display: flex; align-items: flex-start; gap: 10px;
|
||||
margin: 6px 0 10px; padding: 10px 12px; border-radius: 8px;
|
||||
background: rgba(3, 169, 244, 0.08);
|
||||
border: 1px solid rgba(3, 169, 244, 0.25);
|
||||
font-size: 13.5px; line-height: 1.45;
|
||||
}
|
||||
.bn-note ha-icon { --mdc-icon-size: 20px; flex: none; color: var(--primary-color); margin-top: 1px; }
|
||||
.bn-note.warn { background: rgba(255, 167, 38, 0.08); border-color: rgba(255, 167, 38, 0.35); }
|
||||
.bn-note.warn ha-icon { color: var(--warning-color, #ffa726); }
|
||||
.bn-link { color: var(--primary-color); text-decoration: none; }
|
||||
.bn-link:hover { text-decoration: underline; }
|
||||
.bn-more, .bn-sub { color: var(--secondary-text-color); }
|
||||
.bn-sub { display: block; font-size: 12.5px; margin-top: 2px; }
|
||||
|
||||
:host { display: block; }
|
||||
|
||||
.settings-loading {
|
||||
|
||||
+26
-23
@@ -596,40 +596,40 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
|
||||
`
|
||||
: html`
|
||||
<div class="actions primary-row">
|
||||
<button class="btn primary" @click=${this._onComplete} ?disabled=${this._busy}>
|
||||
<ha-icon icon="mdi:check"></ha-icon>
|
||||
<ha-button appearance="accent" variant="success" @click=${this._onComplete} .disabled=${this._busy}>
|
||||
<ha-icon slot="start" icon="mdi:check"></ha-icon>
|
||||
${t("complete", L) || "Complete"}
|
||||
</button>
|
||||
<button class="btn" @click=${() => { this._showSkip = true; }} ?disabled=${this._busy}>
|
||||
<ha-icon icon="mdi:skip-next"></ha-icon>
|
||||
</ha-button>
|
||||
<ha-button appearance="plain" variant="neutral" @click=${() => { this._showSkip = true; }} .disabled=${this._busy}>
|
||||
<ha-icon slot="start" icon="mdi:skip-next"></ha-icon>
|
||||
${t("skip", L) || "Skip"}
|
||||
</button>
|
||||
<button class="btn" @click=${() => { this._showReset = true; }} ?disabled=${this._busy}>
|
||||
<ha-icon icon="mdi:restart"></ha-icon>
|
||||
</ha-button>
|
||||
<ha-button appearance="plain" variant="neutral" @click=${() => { this._showReset = true; }} .disabled=${this._busy}>
|
||||
<ha-icon slot="start" icon="mdi:restart"></ha-icon>
|
||||
${t("reset", L) || "Reset"}
|
||||
</button>
|
||||
</ha-button>
|
||||
</div>
|
||||
${isAdmin
|
||||
? html`
|
||||
<div class="actions secondary-row">
|
||||
<button class="btn ghost" @click=${this._onEdit} ?disabled=${this._busy}>
|
||||
<ha-icon icon="mdi:pencil"></ha-icon>
|
||||
<ha-button size="small" appearance="outlined" variant="neutral" @click=${this._onEdit} .disabled=${this._busy}>
|
||||
<ha-icon slot="start" icon="mdi:pencil"></ha-icon>
|
||||
${t("edit", L) || "Edit"}
|
||||
</button>
|
||||
<button class="btn ghost" @click=${this._onQr} ?disabled=${this._busy}>
|
||||
<ha-icon icon="mdi:qrcode"></ha-icon>
|
||||
</ha-button>
|
||||
<ha-button size="small" appearance="outlined" variant="neutral" @click=${this._onQr} .disabled=${this._busy}>
|
||||
<ha-icon slot="start" icon="mdi:qrcode"></ha-icon>
|
||||
${t("qr_code", L) || "QR"}
|
||||
</button>
|
||||
<button class="btn ghost"
|
||||
</ha-button>
|
||||
<ha-button size="small" appearance="outlined" variant="neutral"
|
||||
@click=${task.archived ? this._onUnarchive : this._onArchive}
|
||||
?disabled=${this._busy}>
|
||||
<ha-icon icon="${task.archived ? 'mdi:archive-arrow-up-outline' : 'mdi:archive-outline'}"></ha-icon>
|
||||
.disabled=${this._busy}>
|
||||
<ha-icon slot="start" icon="${task.archived ? 'mdi:archive-arrow-up-outline' : 'mdi:archive-outline'}"></ha-icon>
|
||||
${task.archived ? (t("unarchive", L) || "Unarchive") : (t("archive", L) || "Archive")}
|
||||
</button>
|
||||
<button class="btn ghost danger" @click=${this._onDelete} ?disabled=${this._busy}>
|
||||
<ha-icon icon="mdi:delete"></ha-icon>
|
||||
</ha-button>
|
||||
<ha-button size="small" appearance="outlined" variant="danger" class="danger" @click=${this._onDelete} .disabled=${this._busy}>
|
||||
<ha-icon slot="start" icon="mdi:delete"></ha-icon>
|
||||
${t("delete", L) || "Delete"}
|
||||
</button>
|
||||
</ha-button>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
@@ -704,6 +704,7 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
|
||||
.actions { display: flex; gap: 8px; }
|
||||
.actions.primary-row { gap: 6px; }
|
||||
.actions.primary-row .btn { flex: 1; }
|
||||
.actions.primary-row ha-button { flex: 1; }
|
||||
/* Edit + QR are admin-tools — left-align as a group; Delete is destructive
|
||||
so it gets pushed to the far right with margin-left:auto for visual
|
||||
separation. Earlier this row was flex-end which left a strange empty
|
||||
@@ -712,9 +713,11 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
|
||||
padding-top: 8px; border-top: 1px solid var(--divider-color);
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.actions.secondary-row .btn.danger {
|
||||
.actions.secondary-row .btn.danger,
|
||||
.actions.secondary-row ha-button.danger {
|
||||
margin-left: auto;
|
||||
}
|
||||
.actions.secondary-row ha-button { --ha-button-font-size: 13px; }
|
||||
.btn {
|
||||
padding: 8px 12px; font-size: 14px;
|
||||
border-radius: 6px; cursor: pointer;
|
||||
|
||||
@@ -122,6 +122,8 @@ interface SettingsCache {
|
||||
checklists: boolean; schedule_time: boolean; completion_actions: boolean;
|
||||
};
|
||||
defaultWarningDays: number;
|
||||
/** #145: global "Task row actions" style (buttons_compact | buttons | icons). */
|
||||
rowActionStyle: string;
|
||||
}
|
||||
|
||||
const FALLBACK_SETTINGS: SettingsCache = {
|
||||
@@ -131,8 +133,21 @@ const FALLBACK_SETTINGS: SettingsCache = {
|
||||
checklists: false, schedule_time: false, completion_actions: false,
|
||||
},
|
||||
defaultWarningDays: 7,
|
||||
rowActionStyle: "buttons_compact",
|
||||
};
|
||||
|
||||
/** The household's row-action style for surfaces outside the panel (the
|
||||
* Lovelace card follows it unless its own `action_style` is set). */
|
||||
export function getRowActionStyle(hass: HomeAssistant): Promise<string> {
|
||||
return fetchSettingsOnce(hass).then((s) => s.rowActionStyle);
|
||||
}
|
||||
|
||||
/** Test hook: the settings cache is module-wide (one fetch per page), which
|
||||
* makes component tests with DIFFERENT settings share the first answer. */
|
||||
export function __resetSettingsCacheForTests(): void {
|
||||
_cachedSettings = null;
|
||||
}
|
||||
|
||||
let _cachedSettings: Promise<SettingsCache> | null = null;
|
||||
|
||||
function fetchSettingsOnce(hass: HomeAssistant): Promise<SettingsCache> {
|
||||
@@ -140,11 +155,12 @@ function fetchSettingsOnce(hass: HomeAssistant): Promise<SettingsCache> {
|
||||
_cachedSettings = hass.connection
|
||||
.sendMessagePromise<{
|
||||
features?: SettingsCache["features"];
|
||||
general?: { default_warning_days?: number };
|
||||
general?: { default_warning_days?: number; row_action_style?: string };
|
||||
}>({ type: "maintenance_supporter/settings" })
|
||||
.then((r) => ({
|
||||
features: r.features ?? FALLBACK_SETTINGS.features,
|
||||
defaultWarningDays: r.general?.default_warning_days ?? 7,
|
||||
rowActionStyle: r.general?.row_action_style ?? FALLBACK_SETTINGS.rowActionStyle,
|
||||
}))
|
||||
.catch(() => FALLBACK_SETTINGS);
|
||||
return _cachedSettings;
|
||||
|
||||
@@ -374,6 +374,7 @@
|
||||
"card_title": "Název",
|
||||
"card_show_header": "Zobrazit záhlaví se statistikami",
|
||||
"card_show_actions": "Zobrazit tlačítka akcí",
|
||||
"card_action_style": "Styl tlačítka Dokončit",
|
||||
"card_compact": "Kompaktní režim",
|
||||
"card_max_items": "Max položek (0 = vše)",
|
||||
"card_filter_status": "Filtrovat podle stavu",
|
||||
@@ -448,6 +449,19 @@
|
||||
"feat_checklists_desc": "Vícestupňové procedury pro dokončení úkolu",
|
||||
"settings_general": "Obecné",
|
||||
"settings_default_warning": "Výchozí dny upozornění",
|
||||
"settings_consumable_threshold": "Práh „spotřební materiál nízký“ (%)",
|
||||
"settings_battery_low_percent": "Práh „baterie slabá“ (%)",
|
||||
"settings_thresholds_hint": "Výchozí hodnoty pro nová Navrhovaná nastavení (zbývající procenta) a pro baterie flotily bez vlastního prahu Battery Notes. Stávající úkoly si ponechají svou hodnotu.",
|
||||
"bn_summary": "{name}: {pct} % pro {n} baterií",
|
||||
"bn_floor_decides": "Tvůj práh {floor} % rozhoduje pro všechny; u každé baterie platí vyšší z obou prahů.",
|
||||
"bn_above_floor": "To je nad tvým prahem {floor} %, takže o všech poznamenaných bateriích rozhoduje {name}.",
|
||||
"bn_overrides": "{n} zařízení s vlastním prahem:",
|
||||
"bn_more": "+ {n} dalších",
|
||||
"settings_row_actions": "Akce v řádcích úkolů",
|
||||
"row_actions_buttons_compact": "Tlačítka (na telefonu jen ikony)",
|
||||
"row_actions_buttons": "Tlačítka s textem",
|
||||
"row_actions_icons": "Ikony (klasické)",
|
||||
"row_actions_follow": "Podle nastavení domácnosti",
|
||||
"settings_panel_enabled": "Boční panel",
|
||||
"settings_panel_title": "Název panelu",
|
||||
"settings_notifications": "Oznámení",
|
||||
@@ -864,6 +878,9 @@
|
||||
"battery_fleet_setup_done": "Flotila baterií nastavena — jeden úkol sleduje všechny.",
|
||||
"update_banner": "Na serveru je novější verze Maintenance Supporter — načtěte znovu pro aktualizaci panelu.",
|
||||
"update_reload": "Načíst znovu",
|
||||
"row_actions_banner": "Dokončit a Přeskočit jsou v řádcích úkolů nyní tlačítka. Dáváte přednost starým ikonám?",
|
||||
"row_actions_keep": "Ponechat tlačítka",
|
||||
"row_actions_back": "Zpět na ikony",
|
||||
"battery_fleet_forecast_overdue": "Předpovězené datum uplynulo — baterie stále hlásí dobrý stav. Pokud jste ji vyměnili, zaznamenejte výměnu; jinak byla předpověď mylná.",
|
||||
"cost_from_parts": "Použít ≈ {amount} z dílů",
|
||||
"dismiss": "Skrýt",
|
||||
|
||||
@@ -376,6 +376,7 @@
|
||||
"card_title": "Titel",
|
||||
"card_show_header": "Vis overskrift med statistik",
|
||||
"card_show_actions": "Vis handlingsknapper",
|
||||
"card_action_style": "Stil for Fuldfør-knappen",
|
||||
"card_compact": "Kompakt tilstand",
|
||||
"card_max_items": "Maks. elementer (0 = alle)",
|
||||
"card_filter_status": "Filtrer efter status",
|
||||
@@ -450,6 +451,19 @@
|
||||
"feat_checklists_desc": "Procedurer i flere trin til fuldførelse af opgaver",
|
||||
"settings_general": "Generelt",
|
||||
"settings_default_warning": "Standard advarselsdage",
|
||||
"settings_consumable_threshold": "Tærskel „forbrugsvare lav“ (%)",
|
||||
"settings_battery_low_percent": "Tærskel „batteri lavt“ (%)",
|
||||
"settings_thresholds_hint": "Standardværdier for nye Foreslåede opsætninger (resterende procent) og for flådebatterier uden egen Battery Notes-tærskel. Eksisterende opgaver beholder deres værdi.",
|
||||
"bn_summary": "{name}: {pct} % for {n} batterier",
|
||||
"bn_floor_decides": "Din tærskel på {floor} % afgør for alle; pr. batteri gælder den højeste af de to tærskler.",
|
||||
"bn_above_floor": "Det er over din tærskel på {floor} %, så {name} afgør for alle noterede batterier.",
|
||||
"bn_overrides": "{n} enheder med egen tærskel:",
|
||||
"bn_more": "+ {n} mere",
|
||||
"settings_row_actions": "Handlinger i opgaverækker",
|
||||
"row_actions_buttons_compact": "Knapper (kun ikoner på telefonen)",
|
||||
"row_actions_buttons": "Knapper med tekst",
|
||||
"row_actions_icons": "Ikoner (klassisk)",
|
||||
"row_actions_follow": "Følg husstandens indstilling",
|
||||
"settings_panel_enabled": "Sidepanel",
|
||||
"settings_panel_title": "Sidepanelets titel",
|
||||
"settings_notifications": "Notifikationer",
|
||||
@@ -864,6 +878,9 @@
|
||||
"battery_fleet_setup_done": "Batteriflåde opsat — én opgave følger alle batterier.",
|
||||
"update_banner": "En nyere version af Maintenance Supporter er på serveren — genindlæs for at opdatere panelet.",
|
||||
"update_reload": "Genindlæs",
|
||||
"row_actions_banner": "Fuldfør og Spring over er nu knapper i opgaverækkerne. Foretrækker du de gamle ikoner?",
|
||||
"row_actions_keep": "Behold knapper",
|
||||
"row_actions_back": "Tilbage til ikoner",
|
||||
"battery_fleet_forecast_overdue": "Forudsagt dato er overskredet — batteriet melder stadig god tilstand. Hvis du har skiftet det, registrér udskiftningen; ellers ramte prognosen forbi.",
|
||||
"cost_from_parts": "Brug ≈ {amount} fra dele",
|
||||
"dismiss": "Afvis",
|
||||
|
||||
@@ -376,6 +376,7 @@
|
||||
"card_title": "Titel",
|
||||
"card_show_header": "Kopfzeile mit Statistiken anzeigen",
|
||||
"card_show_actions": "Aktionsbuttons anzeigen",
|
||||
"card_action_style": "Stil des Erledigt-Buttons",
|
||||
"card_compact": "Kompaktmodus",
|
||||
"card_max_items": "Max. Einträge (0 = alle)",
|
||||
"card_filter_status": "Nach Status filtern",
|
||||
@@ -450,6 +451,19 @@
|
||||
"feat_checklists_desc": "Mehrstufige Verfahren zur Aufgabenerlediung",
|
||||
"settings_general": "Allgemein",
|
||||
"settings_default_warning": "Standard-Warntage",
|
||||
"settings_consumable_threshold": "Schwelle „Verbrauchsmaterial niedrig“ (%)",
|
||||
"settings_battery_low_percent": "Schwelle „Batterie niedrig“ (%)",
|
||||
"settings_thresholds_hint": "Vorgaben für neue Vorgeschlagene Einrichtungen (Prozent-Restwert) und für Flotten-Batterien ohne eigene Battery-Notes-Schwelle. Bestehende Aufgaben behalten ihren Wert.",
|
||||
"bn_summary": "{name}: {pct} % für {n} Batterien",
|
||||
"bn_floor_decides": "Deine {floor}-%-Schwelle entscheidet für alle; pro Batterie zählt die höhere der beiden Schwellen.",
|
||||
"bn_above_floor": "Das liegt über deiner {floor}-%-Schwelle — für alle notierten Batterien entscheidet {name}.",
|
||||
"bn_overrides": "{n} Geräte mit eigener Schwelle:",
|
||||
"bn_more": "+ {n} weitere",
|
||||
"settings_row_actions": "Aktionen in Aufgabenzeilen",
|
||||
"row_actions_buttons_compact": "Buttons (auf dem Handy nur Symbole)",
|
||||
"row_actions_buttons": "Buttons mit Text",
|
||||
"row_actions_icons": "Symbole (klassisch)",
|
||||
"row_actions_follow": "Wie in den Einstellungen",
|
||||
"settings_panel_enabled": "Seitenleisten-Panel",
|
||||
"settings_panel_title": "Panel-Titel",
|
||||
"settings_notifications": "Benachrichtigungen",
|
||||
@@ -864,6 +878,9 @@
|
||||
"battery_fleet_setup_done": "Batterie-Flotte eingerichtet — ein Task überwacht alle Batterien.",
|
||||
"update_banner": "Auf dem Server läuft eine neuere Version von Maintenance Supporter — neu laden, um das Panel zu aktualisieren.",
|
||||
"update_reload": "Neu laden",
|
||||
"row_actions_banner": "Erledigt und Überspringen sind in den Aufgabenzeilen jetzt Buttons. Lieber die alten Symbole?",
|
||||
"row_actions_keep": "Buttons behalten",
|
||||
"row_actions_back": "Zurück zu Symbolen",
|
||||
"battery_fleet_forecast_overdue": "Prognosedatum überschritten — die Batterie meldet sich weiterhin gesund. Falls du sie gewechselt hast, trage den Wechsel nach; andernfalls lag die Prognose daneben.",
|
||||
"cost_from_parts": "≈ {amount} aus Teilen übernehmen",
|
||||
"dismiss": "Ausblenden",
|
||||
|
||||
@@ -376,6 +376,7 @@
|
||||
"card_title": "Title",
|
||||
"card_show_header": "Show header with statistics",
|
||||
"card_show_actions": "Show action buttons",
|
||||
"card_action_style": "Complete button style",
|
||||
"card_compact": "Compact mode",
|
||||
"card_max_items": "Max items (0 = all)",
|
||||
"card_filter_status": "Filter by status",
|
||||
@@ -450,6 +451,19 @@
|
||||
"feat_checklists_desc": "Multi-step procedures for task completion",
|
||||
"settings_general": "General",
|
||||
"settings_default_warning": "Default warning days",
|
||||
"settings_consumable_threshold": "Consumable low threshold (%)",
|
||||
"settings_battery_low_percent": "Battery low threshold (%)",
|
||||
"settings_thresholds_hint": "Defaults for new Suggested setups (percent-remaining consumables) and for fleet batteries without their own Battery Notes threshold. Existing tasks keep their value.",
|
||||
"bn_summary": "{name}: {pct} % for {n} batteries",
|
||||
"bn_floor_decides": "Your {floor} % floor decides for all of them; the higher of the two thresholds counts per battery.",
|
||||
"bn_above_floor": "That is above your {floor} % floor, so {name} decides for all noted batteries.",
|
||||
"bn_overrides": "{n} devices with their own threshold:",
|
||||
"bn_more": "+ {n} more",
|
||||
"settings_row_actions": "Task row actions",
|
||||
"row_actions_buttons_compact": "Buttons (icons only on phones)",
|
||||
"row_actions_buttons": "Buttons with text",
|
||||
"row_actions_icons": "Icons (classic)",
|
||||
"row_actions_follow": "Follow the household setting",
|
||||
"settings_panel_enabled": "Sidebar panel",
|
||||
"settings_panel_title": "Sidebar panel title",
|
||||
"settings_notifications": "Notifications",
|
||||
@@ -864,6 +878,9 @@
|
||||
"battery_fleet_setup_done": "Battery fleet set up — one task tracks all your batteries.",
|
||||
"update_banner": "A newer version of Maintenance Supporter is on the server — reload to update the panel.",
|
||||
"update_reload": "Reload",
|
||||
"row_actions_banner": "Complete and Skip in task rows are now buttons. Prefer the old icons?",
|
||||
"row_actions_keep": "Keep buttons",
|
||||
"row_actions_back": "Back to icons",
|
||||
"battery_fleet_forecast_overdue": "Predicted date passed — the battery still reports healthy. If you swapped it, record the replacement; otherwise the forecast was off.",
|
||||
"cost_from_parts": "Use ≈ {amount} from parts",
|
||||
"dismiss": "Dismiss",
|
||||
|
||||
@@ -374,6 +374,7 @@
|
||||
"card_title": "Título",
|
||||
"card_show_header": "Mostrar encabezado con estadísticas",
|
||||
"card_show_actions": "Mostrar botones de acción",
|
||||
"card_action_style": "Estilo del botón Completar",
|
||||
"card_compact": "Modo compacto",
|
||||
"card_max_items": "Máx. elementos (0 = todos)",
|
||||
"card_filter_status": "Filtrar por estado",
|
||||
@@ -448,6 +449,19 @@
|
||||
"feat_checklists_desc": "Procedimientos de varios pasos para completar tareas",
|
||||
"settings_general": "General",
|
||||
"settings_default_warning": "Días de aviso predeterminados",
|
||||
"settings_consumable_threshold": "Umbral „consumible bajo“ (%)",
|
||||
"settings_battery_low_percent": "Umbral „pila baja“ (%)",
|
||||
"settings_thresholds_hint": "Valores predeterminados para nuevas Configuraciones sugeridas (porcentaje restante) y para pilas de la flota sin umbral propio de Battery Notes. Las tareas existentes conservan su valor.",
|
||||
"bn_summary": "{name}: {pct} % para {n} pilas",
|
||||
"bn_floor_decides": "Tu umbral del {floor} % decide para todas; por pila cuenta el mayor de los dos umbrales.",
|
||||
"bn_above_floor": "Está por encima de tu umbral del {floor} %, así que {name} decide para todas las pilas anotadas.",
|
||||
"bn_overrides": "{n} dispositivos con umbral propio:",
|
||||
"bn_more": "+ {n} más",
|
||||
"settings_row_actions": "Acciones en las filas de tareas",
|
||||
"row_actions_buttons_compact": "Botones (solo iconos en el móvil)",
|
||||
"row_actions_buttons": "Botones con texto",
|
||||
"row_actions_icons": "Iconos (clásico)",
|
||||
"row_actions_follow": "Seguir el ajuste del hogar",
|
||||
"settings_panel_enabled": "Panel lateral",
|
||||
"settings_panel_title": "Título del panel lateral",
|
||||
"settings_notifications": "Notificaciones",
|
||||
@@ -864,6 +878,9 @@
|
||||
"battery_fleet_setup_done": "Flota de baterías configurada — una tarea controla todas.",
|
||||
"update_banner": "Hay una versión más reciente de Maintenance Supporter en el servidor — recarga para actualizar el panel.",
|
||||
"update_reload": "Recargar",
|
||||
"row_actions_banner": "Completar y Omitir ahora son botones en las filas de tareas. ¿Prefieres los iconos antiguos?",
|
||||
"row_actions_keep": "Mantener botones",
|
||||
"row_actions_back": "Volver a los iconos",
|
||||
"battery_fleet_forecast_overdue": "Fecha prevista superada: la batería sigue informando buen estado. Si la cambiaste, registra el reemplazo; si no, la previsión falló.",
|
||||
"cost_from_parts": "Usar ≈ {amount} de las piezas",
|
||||
"dismiss": "Descartar",
|
||||
|
||||
@@ -376,6 +376,7 @@
|
||||
"card_title": "Otsikko",
|
||||
"card_show_header": "Näytä otsikko tilastoineen",
|
||||
"card_show_actions": "Näytä toimintopainikkeet",
|
||||
"card_action_style": "Valmis-painikkeen tyyli",
|
||||
"card_compact": "Tiivis tila",
|
||||
"card_max_items": "Enimmäismäärä (0 = kaikki)",
|
||||
"card_filter_status": "Suodata tilan mukaan",
|
||||
@@ -450,6 +451,19 @@
|
||||
"feat_checklists_desc": "Monivaiheiset menettelyt tehtävän suorittamiseen",
|
||||
"settings_general": "Yleiset",
|
||||
"settings_default_warning": "Oletusvaroituspäivät",
|
||||
"settings_consumable_threshold": "Kynnys „kulutustarvike vähissä“ (%)",
|
||||
"settings_battery_low_percent": "Kynnys „paristo vähissä“ (%)",
|
||||
"settings_thresholds_hint": "Oletukset uusille Ehdotetuille asetuksille (jäljellä oleva prosentti) ja paristokannan paristoille ilman omaa Battery Notes -kynnystä. Olemassa olevat tehtävät säilyttävät arvonsa.",
|
||||
"bn_summary": "{name}: {pct} % {n} paristolle",
|
||||
"bn_floor_decides": "Sinun {floor} %:n kynnyksesi ratkaisee kaikille; paristoa kohden pätee korkeampi kahdesta kynnyksestä.",
|
||||
"bn_above_floor": "Se on sinun {floor} %:n kynnyksesi yläpuolella, joten {name} ratkaisee kaikille kirjatuille paristoille.",
|
||||
"bn_overrides": "{n} laitetta, joilla on oma kynnys:",
|
||||
"bn_more": "+ {n} lisää",
|
||||
"settings_row_actions": "Tehtävärivien toiminnot",
|
||||
"row_actions_buttons_compact": "Painikkeet (puhelimessa vain kuvakkeet)",
|
||||
"row_actions_buttons": "Painikkeet tekstillä",
|
||||
"row_actions_icons": "Kuvakkeet (perinteinen)",
|
||||
"row_actions_follow": "Kotitalouden asetuksen mukaan",
|
||||
"settings_panel_enabled": "Sivupalkin paneeli",
|
||||
"settings_panel_title": "Sivupalkin paneelin otsikko",
|
||||
"settings_notifications": "Ilmoitukset",
|
||||
@@ -864,6 +878,9 @@
|
||||
"battery_fleet_setup_done": "Paristokanta määritetty — yksi tehtävä seuraa kaikkia.",
|
||||
"update_banner": "Palvelimella on uudempi Maintenance Supporter -versio — lataa uudelleen päivittääksesi paneelin.",
|
||||
"update_reload": "Lataa uudelleen",
|
||||
"row_actions_banner": "Valmis ja Ohita ovat tehtäväriveillä nyt painikkeita. Haluatko mieluummin vanhat kuvakkeet?",
|
||||
"row_actions_keep": "Pidä painikkeet",
|
||||
"row_actions_back": "Takaisin kuvakkeisiin",
|
||||
"battery_fleet_forecast_overdue": "Ennustettu päivä on ohitettu — akku ilmoittaa yhä hyvästä kunnosta. Jos vaihdoit sen, kirjaa vaihto; muuten ennuste oli pielessä.",
|
||||
"cost_from_parts": "Käytä ≈ {amount} osista",
|
||||
"dismiss": "Hylkää",
|
||||
|
||||
@@ -374,6 +374,7 @@
|
||||
"card_title": "Titre",
|
||||
"card_show_header": "Afficher l'en-tête avec statistiques",
|
||||
"card_show_actions": "Afficher les boutons d'action",
|
||||
"card_action_style": "Style du bouton Terminer",
|
||||
"card_compact": "Mode compact",
|
||||
"card_max_items": "Nombre max (0 = tous)",
|
||||
"card_filter_status": "Filtrer par statut",
|
||||
@@ -448,6 +449,19 @@
|
||||
"feat_checklists_desc": "Procédures multi-étapes pour la réalisation des tâches",
|
||||
"settings_general": "Général",
|
||||
"settings_default_warning": "Jours d'avertissement par défaut",
|
||||
"settings_consumable_threshold": "Seuil « consommable bas » (%)",
|
||||
"settings_battery_low_percent": "Seuil « pile faible » (%)",
|
||||
"settings_thresholds_hint": "Valeurs par défaut pour les nouvelles Configurations suggérées (pourcentage restant) et pour les piles de la flotte sans seuil Battery Notes propre. Les tâches existantes gardent leur valeur.",
|
||||
"bn_summary": "{name} : {pct} % pour {n} piles",
|
||||
"bn_floor_decides": "Votre seuil de {floor} % décide pour toutes ; par pile, c'est le plus élevé des deux seuils qui compte.",
|
||||
"bn_above_floor": "C'est au-dessus de votre seuil de {floor} % : {name} décide donc pour toutes les piles notées.",
|
||||
"bn_overrides": "{n} appareils avec leur propre seuil :",
|
||||
"bn_more": "+ {n} autres",
|
||||
"settings_row_actions": "Actions des lignes de tâches",
|
||||
"row_actions_buttons_compact": "Boutons (icônes seules sur téléphone)",
|
||||
"row_actions_buttons": "Boutons avec texte",
|
||||
"row_actions_icons": "Icônes (classique)",
|
||||
"row_actions_follow": "Suivre le réglage du foyer",
|
||||
"settings_panel_enabled": "Panneau latéral",
|
||||
"settings_panel_title": "Titre du panneau latéral",
|
||||
"settings_notifications": "Notifications",
|
||||
@@ -864,6 +878,9 @@
|
||||
"battery_fleet_setup_done": "Parc de piles configuré — une tâche suit toutes vos piles.",
|
||||
"update_banner": "Une version plus récente de Maintenance Supporter est sur le serveur — rechargez pour mettre à jour le panneau.",
|
||||
"update_reload": "Recharger",
|
||||
"row_actions_banner": "Terminer et Ignorer sont désormais des boutons dans les lignes de tâches. Vous préférez les anciennes icônes ?",
|
||||
"row_actions_keep": "Garder les boutons",
|
||||
"row_actions_back": "Revenir aux icônes",
|
||||
"battery_fleet_forecast_overdue": "Date prévue dépassée — la batterie se signale toujours en bon état. Si vous l'avez remplacée, enregistrez le remplacement ; sinon la prévision était erronée.",
|
||||
"cost_from_parts": "Reprendre ≈ {amount} des pièces",
|
||||
"dismiss": "Ignorer",
|
||||
|
||||
@@ -376,6 +376,7 @@
|
||||
"card_title": "शीर्षक",
|
||||
"card_show_header": "सांख्यिकी के साथ हेडर दिखाएँ",
|
||||
"card_show_actions": "क्रिया बटन दिखाएँ",
|
||||
"card_action_style": "पूर्ण बटन की शैली",
|
||||
"card_compact": "संक्षिप्त मोड",
|
||||
"card_max_items": "अधिकतम आइटम (0 = सभी)",
|
||||
"card_filter_status": "स्थिति के अनुसार फ़िल्टर करें",
|
||||
@@ -450,6 +451,19 @@
|
||||
"feat_checklists_desc": "कार्य पूर्णता के लिए बहु-चरणीय प्रक्रियाएँ",
|
||||
"settings_general": "सामान्य",
|
||||
"settings_default_warning": "डिफ़ॉल्ट चेतावनी दिन",
|
||||
"settings_consumable_threshold": "उपभोग्य „कम“ सीमा (%)",
|
||||
"settings_battery_low_percent": "बैटरी „कम“ सीमा (%)",
|
||||
"settings_thresholds_hint": "नए सुझाए गए सेटअप (शेष प्रतिशत) और अपनी Battery Notes सीमा न रखने वाली बेड़े की बैटरियों के लिए डिफ़ॉल्ट। मौजूदा कार्य अपना मान रखते हैं।",
|
||||
"bn_summary": "{name}: {n} बैटरियों के लिए {pct} %",
|
||||
"bn_floor_decides": "आपकी {floor} % सीमा सभी के लिए निर्णायक है; प्रत्येक बैटरी के लिए दोनों में से ऊँची सीमा मान्य है।",
|
||||
"bn_above_floor": "यह आपकी {floor} % सीमा से ऊपर है, इसलिए सभी नोट की गई बैटरियों के लिए {name} निर्णायक है।",
|
||||
"bn_overrides": "{n} डिवाइस अपनी सीमा के साथ:",
|
||||
"bn_more": "+ {n} और",
|
||||
"settings_row_actions": "कार्य पंक्ति क्रियाएँ",
|
||||
"row_actions_buttons_compact": "बटन (फ़ोन पर केवल आइकन)",
|
||||
"row_actions_buttons": "टेक्स्ट वाले बटन",
|
||||
"row_actions_icons": "आइकन (क्लासिक)",
|
||||
"row_actions_follow": "घर की सेटिंग के अनुसार",
|
||||
"settings_panel_enabled": "साइडबार पैनल",
|
||||
"settings_panel_title": "साइडबार पैनल शीर्षक",
|
||||
"settings_notifications": "सूचनाएँ",
|
||||
@@ -864,6 +878,9 @@
|
||||
"battery_fleet_setup_done": "बैटरी फ्लीट सेट — एक कार्य सभी बैटरियों को ट्रैक करता है।",
|
||||
"update_banner": "सर्वर पर Maintenance Supporter का नया संस्करण है — पैनल अपडेट करने के लिए पुनः लोड करें।",
|
||||
"update_reload": "पुनः लोड करें",
|
||||
"row_actions_banner": "कार्य पंक्तियों में पूर्ण और छोड़ें अब बटन हैं। पुराने आइकन पसंद हैं?",
|
||||
"row_actions_keep": "बटन रखें",
|
||||
"row_actions_back": "आइकन पर वापस जाएँ",
|
||||
"battery_fleet_forecast_overdue": "अनुमानित तिथि बीत गई — बैटरी अब भी अच्छी स्थिति बता रही है। यदि आपने इसे बदला है, तो बदलाव दर्ज करें; अन्यथा पूर्वानुमान गलत था।",
|
||||
"cost_from_parts": "पुर्ज़ों से ≈ {amount} लें",
|
||||
"dismiss": "हटाएँ",
|
||||
|
||||
@@ -376,6 +376,7 @@
|
||||
"card_title": "Cím",
|
||||
"card_show_header": "Fejléc megjelenítése statisztikákkal",
|
||||
"card_show_actions": "Műveletgombok megjelenítése",
|
||||
"card_action_style": "A Kész gomb stílusa",
|
||||
"card_compact": "Kompakt mód",
|
||||
"card_max_items": "Max. elemszám (0 = mind)",
|
||||
"card_filter_status": "Szűrés állapot szerint",
|
||||
@@ -450,6 +451,19 @@
|
||||
"feat_checklists_desc": "Többlépéses eljárások a feladatok elvégzéséhez",
|
||||
"settings_general": "Általános",
|
||||
"settings_default_warning": "Alapértelmezett figyelmeztetési napok",
|
||||
"settings_consumable_threshold": "„Fogyóeszköz alacsony” küszöb (%)",
|
||||
"settings_battery_low_percent": "„Alacsony akkumulátor” küszöb (%)",
|
||||
"settings_thresholds_hint": "Alapértékek az új Javasolt beállításokhoz (százalékos maradék) és a saját Battery Notes-küszöb nélküli flottaelemekhez. A meglévő feladatok megtartják az értéküket.",
|
||||
"bn_summary": "{name}: {pct} % {n} elemhez",
|
||||
"bn_floor_decides": "A te {floor} %-os küszöböd dönt mindegyiknél; elemenként a két küszöb közül a magasabb számít.",
|
||||
"bn_above_floor": "Ez a te {floor} %-os küszöböd felett van, így minden feljegyzett elemnél a {name} dönt.",
|
||||
"bn_overrides": "{n} eszköz saját küszöbbel:",
|
||||
"bn_more": "+ még {n}",
|
||||
"settings_row_actions": "Műveletek a feladatsorokban",
|
||||
"row_actions_buttons_compact": "Gombok (telefonon csak ikonok)",
|
||||
"row_actions_buttons": "Gombok szöveggel",
|
||||
"row_actions_icons": "Ikonok (klasszikus)",
|
||||
"row_actions_follow": "A háztartás beállítása szerint",
|
||||
"settings_panel_enabled": "Oldalsáv-panel",
|
||||
"settings_panel_title": "Oldalsáv-panel címe",
|
||||
"settings_notifications": "Értesítések",
|
||||
@@ -864,6 +878,9 @@
|
||||
"battery_fleet_setup_done": "Elemflotta beállítva — egyetlen feladat követi az összes elemet.",
|
||||
"update_banner": "A Maintenance Supporter újabb verziója érhető el a szerveren — töltse újra az oldalt a panel frissítéséhez.",
|
||||
"update_reload": "Újratöltés",
|
||||
"row_actions_banner": "A Kész és a Kihagyás a feladatsorokban mostantól gombok. Inkább a régi ikonokat szeretné?",
|
||||
"row_actions_keep": "Gombok megtartása",
|
||||
"row_actions_back": "Vissza az ikonokhoz",
|
||||
"battery_fleet_forecast_overdue": "Az előrejelzett dátum elmúlt — az elem továbbra is jó állapotot jelez. Ha kicserélted, rögzítsd a cserét; különben az előrejelzés tévedett.",
|
||||
"cost_from_parts": "≈ {amount} átvétele az alkatrészekből",
|
||||
"dismiss": "Elrejtés",
|
||||
|
||||
@@ -374,6 +374,7 @@
|
||||
"card_title": "Titolo",
|
||||
"card_show_header": "Mostra intestazione con statistiche",
|
||||
"card_show_actions": "Mostra pulsanti azione",
|
||||
"card_action_style": "Stile del pulsante Completa",
|
||||
"card_compact": "Modalità compatta",
|
||||
"card_max_items": "Max elementi (0 = tutti)",
|
||||
"card_filter_status": "Filtra per stato",
|
||||
@@ -448,6 +449,19 @@
|
||||
"feat_checklists_desc": "Procedure multi-fase per il completamento delle attività",
|
||||
"settings_general": "Generale",
|
||||
"settings_default_warning": "Giorni di avviso predefiniti",
|
||||
"settings_consumable_threshold": "Soglia „consumabile basso“ (%)",
|
||||
"settings_battery_low_percent": "Soglia „batteria scarica“ (%)",
|
||||
"settings_thresholds_hint": "Valori predefiniti per le nuove Configurazioni suggerite (percentuale residua) e per le batterie della flotta senza soglia Battery Notes propria. Le attività esistenti mantengono il loro valore.",
|
||||
"bn_summary": "{name}: {pct} % per {n} batterie",
|
||||
"bn_floor_decides": "La tua soglia del {floor} % decide per tutte; per ogni batteria conta la più alta delle due soglie.",
|
||||
"bn_above_floor": "È sopra la tua soglia del {floor} %, quindi {name} decide per tutte le batterie annotate.",
|
||||
"bn_overrides": "{n} dispositivi con soglia propria:",
|
||||
"bn_more": "+ altri {n}",
|
||||
"settings_row_actions": "Azioni nelle righe delle attività",
|
||||
"row_actions_buttons_compact": "Pulsanti (solo icone sul telefono)",
|
||||
"row_actions_buttons": "Pulsanti con testo",
|
||||
"row_actions_icons": "Icone (classico)",
|
||||
"row_actions_follow": "Segui l'impostazione della casa",
|
||||
"settings_panel_enabled": "Pannello laterale",
|
||||
"settings_panel_title": "Titolo pannello laterale",
|
||||
"settings_notifications": "Notifiche",
|
||||
@@ -864,6 +878,9 @@
|
||||
"battery_fleet_setup_done": "Parco batterie configurato — un'attività monitora tutte.",
|
||||
"update_banner": "Sul server è presente una versione più recente di Maintenance Supporter — ricarica per aggiornare il pannello.",
|
||||
"update_reload": "Ricarica",
|
||||
"row_actions_banner": "Completa e Salta nelle righe delle attività ora sono pulsanti. Preferisci le vecchie icone?",
|
||||
"row_actions_keep": "Mantieni i pulsanti",
|
||||
"row_actions_back": "Torna alle icone",
|
||||
"battery_fleet_forecast_overdue": "Data prevista superata — la batteria risulta ancora in buono stato. Se l'hai sostituita, registra la sostituzione; altrimenti la previsione era errata.",
|
||||
"cost_from_parts": "Usa ≈ {amount} dai ricambi",
|
||||
"dismiss": "Ignora",
|
||||
|
||||
@@ -376,6 +376,7 @@
|
||||
"card_title": "タイトル",
|
||||
"card_show_header": "統計付きヘッダーを表示",
|
||||
"card_show_actions": "操作ボタンを表示",
|
||||
"card_action_style": "完了ボタンのスタイル",
|
||||
"card_compact": "コンパクトモード",
|
||||
"card_max_items": "最大表示件数(0=すべて)",
|
||||
"card_filter_status": "状態で絞り込み",
|
||||
@@ -450,6 +451,19 @@
|
||||
"feat_checklists_desc": "タスク完了のための複数手順の手続き",
|
||||
"settings_general": "一般",
|
||||
"settings_default_warning": "既定の警告日数",
|
||||
"settings_consumable_threshold": "消耗品「残量少」しきい値(%)",
|
||||
"settings_battery_low_percent": "電池「残量少」しきい値(%)",
|
||||
"settings_thresholds_hint": "新しい「推奨セットアップ」(残量パーセント)と、独自の Battery Notes しきい値を持たないバッテリー一覧の電池に適用される既定値です。既存のタスクは値を保持します。",
|
||||
"bn_summary": "{name}:{n} 個の電池に {pct} %",
|
||||
"bn_floor_decides": "あなたの {floor} % のしきい値がすべてに適用されます。電池ごとに高い方のしきい値が有効です。",
|
||||
"bn_above_floor": "これはあなたの {floor} % のしきい値を上回るため、記録済みのすべての電池は {name} が決定します。",
|
||||
"bn_overrides": "独自のしきい値を持つデバイス {n} 台:",
|
||||
"bn_more": "+ 他 {n} 件",
|
||||
"settings_row_actions": "タスク行の操作",
|
||||
"row_actions_buttons_compact": "ボタン(スマートフォンではアイコンのみ)",
|
||||
"row_actions_buttons": "テキスト付きボタン",
|
||||
"row_actions_icons": "アイコン(従来)",
|
||||
"row_actions_follow": "全体設定に従う",
|
||||
"settings_panel_enabled": "サイドバーパネル",
|
||||
"settings_panel_title": "サイドバーパネルのタイトル",
|
||||
"settings_notifications": "通知",
|
||||
@@ -864,6 +878,9 @@
|
||||
"battery_fleet_setup_done": "電池フリートを設定 — 1つのタスクで全電池を管理。",
|
||||
"update_banner": "サーバーに新しいバージョンの Maintenance Supporter があります — 再読み込みしてパネルを更新してください。",
|
||||
"update_reload": "再読み込み",
|
||||
"row_actions_banner": "タスク行の「完了」と「スキップ」がボタンになりました。以前のアイコンに戻しますか?",
|
||||
"row_actions_keep": "ボタンのままにする",
|
||||
"row_actions_back": "アイコンに戻す",
|
||||
"battery_fleet_forecast_overdue": "予測日を過ぎましたが、電池はまだ正常と報告しています。交換済みなら交換を記録してください。そうでなければ予測が外れています。",
|
||||
"cost_from_parts": "部品から ≈ {amount} を適用",
|
||||
"dismiss": "閉じる",
|
||||
|
||||
@@ -376,6 +376,7 @@
|
||||
"card_title": "제목",
|
||||
"card_show_header": "통계 헤더 표시",
|
||||
"card_show_actions": "동작 버튼 표시",
|
||||
"card_action_style": "완료 버튼 스타일",
|
||||
"card_compact": "컴팩트 모드",
|
||||
"card_max_items": "최대 항목 수 (0 = 전체)",
|
||||
"card_filter_status": "상태로 필터",
|
||||
@@ -450,6 +451,19 @@
|
||||
"feat_checklists_desc": "작업 완료를 위한 다단계 절차",
|
||||
"settings_general": "일반",
|
||||
"settings_default_warning": "기본 경고 일수",
|
||||
"settings_consumable_threshold": "소모품 „부족“ 임계값 (%)",
|
||||
"settings_battery_low_percent": "배터리 „부족“ 임계값 (%)",
|
||||
"settings_thresholds_hint": "새 추천 설정(남은 비율)과 자체 Battery Notes 임계값이 없는 배터리 목록 배터리의 기본값입니다. 기존 작업은 값을 유지합니다.",
|
||||
"bn_summary": "{name}: 배터리 {n}개에 {pct} %",
|
||||
"bn_floor_decides": "당신의 {floor} % 임계값이 모두에 적용됩니다. 배터리마다 두 임계값 중 높은 쪽이 적용됩니다.",
|
||||
"bn_above_floor": "이는 당신의 {floor} % 임계값보다 높으므로, 기록된 모든 배터리는 {name}이(가) 결정합니다.",
|
||||
"bn_overrides": "자체 임계값이 있는 기기 {n}개:",
|
||||
"bn_more": "+ {n}개 더",
|
||||
"settings_row_actions": "작업 행 동작",
|
||||
"row_actions_buttons_compact": "버튼 (휴대폰에서는 아이콘만)",
|
||||
"row_actions_buttons": "텍스트가 있는 버튼",
|
||||
"row_actions_icons": "아이콘 (기본)",
|
||||
"row_actions_follow": "가정 설정을 따름",
|
||||
"settings_panel_enabled": "사이드바 패널",
|
||||
"settings_panel_title": "사이드바 패널 제목",
|
||||
"settings_notifications": "알림",
|
||||
@@ -864,6 +878,9 @@
|
||||
"battery_fleet_setup_done": "배터리 플릿이 설정되었습니다 — 하나의 작업이 모든 배터리를 추적합니다.",
|
||||
"update_banner": "서버에 Maintenance Supporter의 새 버전이 있습니다 — 패널을 업데이트하려면 새로 고침하세요.",
|
||||
"update_reload": "새로 고침",
|
||||
"row_actions_banner": "작업 행의 완료와 건너뛰기가 이제 버튼으로 표시됩니다. 이전 아이콘이 더 좋으신가요?",
|
||||
"row_actions_keep": "버튼 유지",
|
||||
"row_actions_back": "아이콘으로 되돌리기",
|
||||
"battery_fleet_forecast_overdue": "예측 날짜가 지났지만 배터리는 여전히 정상으로 보고됩니다. 교체했다면 교체를 기록하세요. 아니라면 예측이 빗나간 것입니다.",
|
||||
"cost_from_parts": "부품에서 ≈ {amount} 적용",
|
||||
"dismiss": "닫기",
|
||||
|
||||
@@ -376,6 +376,7 @@
|
||||
"card_title": "Tittel",
|
||||
"card_show_header": "Vis topptekst med statistikk",
|
||||
"card_show_actions": "Vis handlingsknapper",
|
||||
"card_action_style": "Stil på Fullfør-knappen",
|
||||
"card_compact": "Kompakt modus",
|
||||
"card_max_items": "Maks elementer (0 = alle)",
|
||||
"card_filter_status": "Filtrer etter status",
|
||||
@@ -450,6 +451,19 @@
|
||||
"feat_checklists_desc": "Flertrinnsprosedyrer for fullføring av oppgaver",
|
||||
"settings_general": "Generelt",
|
||||
"settings_default_warning": "Standard varslingsdager",
|
||||
"settings_consumable_threshold": "Terskel „forbruksvare lav“ (%)",
|
||||
"settings_battery_low_percent": "Terskel „batteri lavt“ (%)",
|
||||
"settings_thresholds_hint": "Standardverdier for nye Foreslåtte oppsett (gjenværende prosent) og for flåtebatterier uten egen Battery Notes-terskel. Eksisterende oppgaver beholder verdien sin.",
|
||||
"bn_summary": "{name}: {pct} % for {n} batterier",
|
||||
"bn_floor_decides": "Din terskel på {floor} % avgjør for alle; per batteri gjelder den høyeste av de to tersklene.",
|
||||
"bn_above_floor": "Det er over din terskel på {floor} %, så {name} avgjør for alle noterte batterier.",
|
||||
"bn_overrides": "{n} enheter med egen terskel:",
|
||||
"bn_more": "+ {n} til",
|
||||
"settings_row_actions": "Handlinger i oppgaverader",
|
||||
"row_actions_buttons_compact": "Knapper (bare ikoner på mobil)",
|
||||
"row_actions_buttons": "Knapper med tekst",
|
||||
"row_actions_icons": "Ikoner (klassisk)",
|
||||
"row_actions_follow": "Følg husstandens innstilling",
|
||||
"settings_panel_enabled": "Sidepanel",
|
||||
"settings_panel_title": "Tittel på sidepanel",
|
||||
"settings_notifications": "Varsler",
|
||||
@@ -864,6 +878,9 @@
|
||||
"battery_fleet_setup_done": "Batteriflåte satt opp — én oppgave følger alle batteriene.",
|
||||
"update_banner": "En nyere versjon av Maintenance Supporter er på serveren — last inn på nytt for å oppdatere panelet.",
|
||||
"update_reload": "Last inn på nytt",
|
||||
"row_actions_banner": "Fullfør og Hopp over er nå knapper i oppgaveradene. Foretrekker du de gamle ikonene?",
|
||||
"row_actions_keep": "Behold knapper",
|
||||
"row_actions_back": "Tilbake til ikoner",
|
||||
"battery_fleet_forecast_overdue": "Forutsagt dato er passert — batteriet melder fortsatt god tilstand. Hvis du byttet det, registrer byttet; ellers bommet prognosen.",
|
||||
"cost_from_parts": "Bruk ≈ {amount} fra deler",
|
||||
"dismiss": "Avvis",
|
||||
|
||||
@@ -374,6 +374,7 @@
|
||||
"card_title": "Titel",
|
||||
"card_show_header": "Koptekst met statistieken tonen",
|
||||
"card_show_actions": "Actieknoppen tonen",
|
||||
"card_action_style": "Stijl van de Voltooien-knop",
|
||||
"card_compact": "Compacte modus",
|
||||
"card_max_items": "Max items (0 = alle)",
|
||||
"card_filter_status": "Filteren op status",
|
||||
@@ -448,6 +449,19 @@
|
||||
"feat_checklists_desc": "Meerstaps procedures voor taakvoltooiing",
|
||||
"settings_general": "Algemeen",
|
||||
"settings_default_warning": "Standaard waarschuwingsdagen",
|
||||
"settings_consumable_threshold": "Drempel „verbruiksartikel laag“ (%)",
|
||||
"settings_battery_low_percent": "Drempel „batterij laag“ (%)",
|
||||
"settings_thresholds_hint": "Standaardwaarden voor nieuwe Voorgestelde installaties (resterend percentage) en voor vlootbatterijen zonder eigen Battery Notes-drempel. Bestaande taken behouden hun waarde.",
|
||||
"bn_summary": "{name}: {pct} % voor {n} batterijen",
|
||||
"bn_floor_decides": "Jouw drempel van {floor} % beslist voor allemaal; per batterij telt de hoogste van de twee drempels.",
|
||||
"bn_above_floor": "Dat ligt boven jouw drempel van {floor} %, dus {name} beslist voor alle genoteerde batterijen.",
|
||||
"bn_overrides": "{n} apparaten met een eigen drempel:",
|
||||
"bn_more": "+ {n} meer",
|
||||
"settings_row_actions": "Acties in taakrijen",
|
||||
"row_actions_buttons_compact": "Knoppen (op telefoons alleen pictogrammen)",
|
||||
"row_actions_buttons": "Knoppen met tekst",
|
||||
"row_actions_icons": "Pictogrammen (klassiek)",
|
||||
"row_actions_follow": "Volg de instelling van het huishouden",
|
||||
"settings_panel_enabled": "Zijbalkpaneel",
|
||||
"settings_panel_title": "Titel zijbalkpaneel",
|
||||
"settings_notifications": "Meldingen",
|
||||
@@ -864,6 +878,9 @@
|
||||
"battery_fleet_setup_done": "Batterijvloot ingesteld — één taak volgt al je batterijen.",
|
||||
"update_banner": "Er staat een nieuwere versie van Maintenance Supporter op de server — herlaad om het paneel bij te werken.",
|
||||
"update_reload": "Herladen",
|
||||
"row_actions_banner": "Voltooien en Overslaan zijn in taakrijen nu knoppen. Liever de oude pictogrammen?",
|
||||
"row_actions_keep": "Knoppen houden",
|
||||
"row_actions_back": "Terug naar pictogrammen",
|
||||
"battery_fleet_forecast_overdue": "Voorspelde datum verstreken — de batterij meldt zich nog steeds gezond. Heb je hem vervangen, registreer dan de vervanging; anders zat de voorspelling ernaast.",
|
||||
"cost_from_parts": "≈ {amount} uit onderdelen overnemen",
|
||||
"dismiss": "Verbergen",
|
||||
|
||||
@@ -374,6 +374,7 @@
|
||||
"card_title": "Tytuł",
|
||||
"card_show_header": "Pokaż nagłówek ze statystykami",
|
||||
"card_show_actions": "Pokaż przyciski akcji",
|
||||
"card_action_style": "Styl przycisku Ukończ",
|
||||
"card_compact": "Tryb kompaktowy",
|
||||
"card_max_items": "Maks. elementów (0 = wszystkie)",
|
||||
"card_filter_status": "Filtruj wg statusu",
|
||||
@@ -448,6 +449,19 @@
|
||||
"feat_checklists_desc": "Wieloetapowe procedury wykonania zadania",
|
||||
"settings_general": "Ogólne",
|
||||
"settings_default_warning": "Domyślne dni ostrzeżenia",
|
||||
"settings_consumable_threshold": "Próg „materiał eksploatacyjny niski” (%)",
|
||||
"settings_battery_low_percent": "Próg „bateria słaba” (%)",
|
||||
"settings_thresholds_hint": "Wartości domyślne dla nowych Sugerowanych konfiguracji (procent pozostały) i dla baterii floty bez własnego progu Battery Notes. Istniejące zadania zachowują swoją wartość.",
|
||||
"bn_summary": "{name}: {pct} % dla {n} baterii",
|
||||
"bn_floor_decides": "Twój próg {floor} % decyduje dla wszystkich; dla każdej baterii liczy się wyższy z dwóch progów.",
|
||||
"bn_above_floor": "To powyżej twojego progu {floor} %, więc o wszystkich odnotowanych bateriach decyduje {name}.",
|
||||
"bn_overrides": "{n} urządzeń z własnym progiem:",
|
||||
"bn_more": "+ {n} więcej",
|
||||
"settings_row_actions": "Akcje w wierszach zadań",
|
||||
"row_actions_buttons_compact": "Przyciski (na telefonie tylko ikony)",
|
||||
"row_actions_buttons": "Przyciski z tekstem",
|
||||
"row_actions_icons": "Ikony (klasyczne)",
|
||||
"row_actions_follow": "Zgodnie z ustawieniem domu",
|
||||
"settings_panel_enabled": "Panel boczny",
|
||||
"settings_panel_title": "Tytuł panelu bocznego",
|
||||
"settings_notifications": "Powiadomienia",
|
||||
@@ -864,6 +878,9 @@
|
||||
"battery_fleet_setup_done": "Flota baterii skonfigurowana — jedno zadanie śledzi wszystkie.",
|
||||
"update_banner": "Na serwerze jest nowsza wersja Maintenance Supporter — załaduj ponownie, aby zaktualizować panel.",
|
||||
"update_reload": "Załaduj ponownie",
|
||||
"row_actions_banner": "Ukończ i Pomiń w wierszach zadań są teraz przyciskami. Wolisz stare ikony?",
|
||||
"row_actions_keep": "Zachowaj przyciski",
|
||||
"row_actions_back": "Wróć do ikon",
|
||||
"battery_fleet_forecast_overdue": "Przewidywana data minęła — bateria nadal zgłasza dobry stan. Jeśli ją wymieniono, zapisz wymianę; w przeciwnym razie prognoza była błędna.",
|
||||
"cost_from_parts": "Użyj ≈ {amount} z części",
|
||||
"dismiss": "Odrzuć",
|
||||
|
||||
@@ -376,6 +376,7 @@
|
||||
"card_title": "Título",
|
||||
"card_show_header": "Mostrar cabeçalho com estatísticas",
|
||||
"card_show_actions": "Mostrar botões de ação",
|
||||
"card_action_style": "Estilo do botão Concluir",
|
||||
"card_compact": "Modo compacto",
|
||||
"card_max_items": "Máx. de itens (0 = todos)",
|
||||
"card_filter_status": "Filtrar por status",
|
||||
@@ -450,6 +451,19 @@
|
||||
"feat_checklists_desc": "Procedimentos em várias etapas para concluir tarefas",
|
||||
"settings_general": "Geral",
|
||||
"settings_default_warning": "Dias de aviso padrão",
|
||||
"settings_consumable_threshold": "Limite „consumível baixo“ (%)",
|
||||
"settings_battery_low_percent": "Limite „pilha fraca“ (%)",
|
||||
"settings_thresholds_hint": "Padrões para novas Configurações sugeridas (porcentagem restante) e para pilhas da frota sem limite próprio do Battery Notes. As tarefas existentes mantêm seu valor.",
|
||||
"bn_summary": "{name}: {pct} % para {n} pilhas",
|
||||
"bn_floor_decides": "Seu limite de {floor} % decide para todas; por pilha vale o maior dos dois limites.",
|
||||
"bn_above_floor": "Está acima do seu limite de {floor} %, então o {name} decide para todas as pilhas anotadas.",
|
||||
"bn_overrides": "{n} dispositivos com limite próprio:",
|
||||
"bn_more": "+ {n} mais",
|
||||
"settings_row_actions": "Ações nas linhas de tarefas",
|
||||
"row_actions_buttons_compact": "Botões (só ícones no celular)",
|
||||
"row_actions_buttons": "Botões com texto",
|
||||
"row_actions_icons": "Ícones (clássico)",
|
||||
"row_actions_follow": "Seguir a configuração da casa",
|
||||
"settings_panel_enabled": "Painel na barra lateral",
|
||||
"settings_panel_title": "Título do painel na barra lateral",
|
||||
"settings_notifications": "Notificações",
|
||||
@@ -864,6 +878,9 @@
|
||||
"battery_fleet_setup_done": "Frota de baterias configurada — uma única tarefa acompanha todas as suas baterias.",
|
||||
"update_banner": "Há uma versão mais nova do Maintenance Supporter no servidor — recarregue para atualizar o painel.",
|
||||
"update_reload": "Recarregar",
|
||||
"row_actions_banner": "Concluir e Pular agora são botões nas linhas de tarefas. Prefere os ícones antigos?",
|
||||
"row_actions_keep": "Manter botões",
|
||||
"row_actions_back": "Voltar aos ícones",
|
||||
"battery_fleet_forecast_overdue": "Data prevista ultrapassada — a bateria ainda reporta bom estado. Se você a trocou, registre a troca; caso contrário, a previsão errou.",
|
||||
"cost_from_parts": "Usar ≈ {amount} das peças",
|
||||
"dismiss": "Dispensar",
|
||||
|
||||
@@ -374,6 +374,7 @@
|
||||
"card_title": "Título",
|
||||
"card_show_header": "Mostrar cabeçalho com estatísticas",
|
||||
"card_show_actions": "Mostrar botões de ação",
|
||||
"card_action_style": "Estilo do botão Concluir",
|
||||
"card_compact": "Modo compacto",
|
||||
"card_max_items": "Máx. itens (0 = todos)",
|
||||
"card_filter_status": "Filtrar por estado",
|
||||
@@ -448,6 +449,19 @@
|
||||
"feat_checklists_desc": "Procedimentos com vários passos para conclusão de tarefas",
|
||||
"settings_general": "Geral",
|
||||
"settings_default_warning": "Dias de aviso predefinidos",
|
||||
"settings_consumable_threshold": "Limiar „consumível baixo“ (%)",
|
||||
"settings_battery_low_percent": "Limiar „pilha fraca“ (%)",
|
||||
"settings_thresholds_hint": "Predefinições para novas Configurações sugeridas (percentagem restante) e para pilhas da frota sem limiar Battery Notes próprio. As tarefas existentes mantêm o seu valor.",
|
||||
"bn_summary": "{name}: {pct} % para {n} pilhas",
|
||||
"bn_floor_decides": "O teu limiar de {floor} % decide para todas; por pilha conta o mais alto dos dois limiares.",
|
||||
"bn_above_floor": "Está acima do teu limiar de {floor} %, por isso o {name} decide para todas as pilhas anotadas.",
|
||||
"bn_overrides": "{n} dispositivos com limiar próprio:",
|
||||
"bn_more": "+ {n} mais",
|
||||
"settings_row_actions": "Ações nas linhas de tarefas",
|
||||
"row_actions_buttons_compact": "Botões (só ícones no telemóvel)",
|
||||
"row_actions_buttons": "Botões com texto",
|
||||
"row_actions_icons": "Ícones (clássico)",
|
||||
"row_actions_follow": "Seguir a definição da casa",
|
||||
"settings_panel_enabled": "Painel lateral",
|
||||
"settings_panel_title": "Título do painel lateral",
|
||||
"settings_notifications": "Notificações",
|
||||
@@ -864,6 +878,9 @@
|
||||
"battery_fleet_setup_done": "Frota de baterias configurada — uma tarefa acompanha todas.",
|
||||
"update_banner": "Há uma versão mais recente do Maintenance Supporter no servidor — recarregue para atualizar o painel.",
|
||||
"update_reload": "Recarregar",
|
||||
"row_actions_banner": "Concluir e Ignorar são agora botões nas linhas de tarefas. Prefere os ícones antigos?",
|
||||
"row_actions_keep": "Manter botões",
|
||||
"row_actions_back": "Voltar aos ícones",
|
||||
"battery_fleet_forecast_overdue": "Data prevista ultrapassada — a bateria continua a reportar bom estado. Se a substituiu, registe a substituição; caso contrário, a previsão falhou.",
|
||||
"cost_from_parts": "Usar ≈ {amount} das peças",
|
||||
"dismiss": "Dispensar",
|
||||
|
||||
@@ -374,6 +374,7 @@
|
||||
"card_title": "Заголовок",
|
||||
"card_show_header": "Показывать заголовок со статистикой",
|
||||
"card_show_actions": "Показывать кнопки действий",
|
||||
"card_action_style": "Стиль кнопки «Выполнено»",
|
||||
"card_compact": "Компактный режим",
|
||||
"card_max_items": "Макс. элементов (0 = все)",
|
||||
"card_filter_status": "Фильтровать по статусу",
|
||||
@@ -448,6 +449,19 @@
|
||||
"feat_checklists_desc": "Многошаговые процедуры для выполнения задачи",
|
||||
"settings_general": "Основные",
|
||||
"settings_default_warning": "Дни предупреждения по умолчанию",
|
||||
"settings_consumable_threshold": "Порог «расходник заканчивается» (%)",
|
||||
"settings_battery_low_percent": "Порог «батарея разряжена» (%)",
|
||||
"settings_thresholds_hint": "Значения по умолчанию для новых «Предлагаемых настроек» (остаток в процентах) и для батарей парка без собственного порога Battery Notes. Существующие задачи сохраняют своё значение.",
|
||||
"bn_summary": "{name}: {pct} % для {n} батарей",
|
||||
"bn_floor_decides": "Ваш порог {floor} % действует для всех; для каждой батареи учитывается больший из двух порогов.",
|
||||
"bn_above_floor": "Это выше вашего порога {floor} %, поэтому для всех отмеченных батарей решает {name}.",
|
||||
"bn_overrides": "{n} устройств с собственным порогом:",
|
||||
"bn_more": "+ ещё {n}",
|
||||
"settings_row_actions": "Действия в строках задач",
|
||||
"row_actions_buttons_compact": "Кнопки (на телефоне только значки)",
|
||||
"row_actions_buttons": "Кнопки с текстом",
|
||||
"row_actions_icons": "Значки (классический вид)",
|
||||
"row_actions_follow": "Как в настройках дома",
|
||||
"settings_panel_enabled": "Боковая панель",
|
||||
"settings_panel_title": "Заголовок панели",
|
||||
"settings_notifications": "Уведомления",
|
||||
@@ -864,6 +878,9 @@
|
||||
"battery_fleet_setup_done": "Парк батарей настроен — одна задача следит за всеми.",
|
||||
"update_banner": "На сервере более новая версия Maintenance Supporter — перезагрузите, чтобы обновить панель.",
|
||||
"update_reload": "Перезагрузить",
|
||||
"row_actions_banner": "«Выполнено» и «Пропустить» в строках задач теперь кнопки. Предпочитаете старые значки?",
|
||||
"row_actions_keep": "Оставить кнопки",
|
||||
"row_actions_back": "Вернуть значки",
|
||||
"battery_fleet_forecast_overdue": "Прогнозируемая дата прошла — батарея по-прежнему сообщает о хорошем состоянии. Если вы её заменили, зафиксируйте замену; иначе прогноз оказался неверным.",
|
||||
"cost_from_parts": "Взять ≈ {amount} из запчастей",
|
||||
"dismiss": "Скрыть",
|
||||
|
||||
@@ -374,6 +374,7 @@
|
||||
"card_title": "Titel",
|
||||
"card_show_header": "Visa rubrik med statistik",
|
||||
"card_show_actions": "Visa åtgärdsknappar",
|
||||
"card_action_style": "Stil på Slutför-knappen",
|
||||
"card_compact": "Kompakt läge",
|
||||
"card_max_items": "Max objekt (0 = alla)",
|
||||
"card_filter_status": "Filtrera efter status",
|
||||
@@ -448,6 +449,19 @@
|
||||
"feat_checklists_desc": "Flerstegs procedurer för uppgiftens slutförande",
|
||||
"settings_general": "Allmänt",
|
||||
"settings_default_warning": "Standard varningsdagar",
|
||||
"settings_consumable_threshold": "Tröskel „förbrukningsartikel låg“ (%)",
|
||||
"settings_battery_low_percent": "Tröskel „batteri lågt“ (%)",
|
||||
"settings_thresholds_hint": "Standardvärden för nya Föreslagna inställningar (återstående procent) och för flottans batterier utan egen Battery Notes-tröskel. Befintliga uppgifter behåller sitt värde.",
|
||||
"bn_summary": "{name}: {pct} % för {n} batterier",
|
||||
"bn_floor_decides": "Din tröskel på {floor} % avgör för alla; per batteri gäller den högre av de två trösklarna.",
|
||||
"bn_above_floor": "Det är över din tröskel på {floor} %, så {name} avgör för alla noterade batterier.",
|
||||
"bn_overrides": "{n} enheter med egen tröskel:",
|
||||
"bn_more": "+ {n} till",
|
||||
"settings_row_actions": "Åtgärder i uppgiftsrader",
|
||||
"row_actions_buttons_compact": "Knappar (endast ikoner på mobilen)",
|
||||
"row_actions_buttons": "Knappar med text",
|
||||
"row_actions_icons": "Ikoner (klassiskt)",
|
||||
"row_actions_follow": "Följ hushållets inställning",
|
||||
"settings_panel_enabled": "Sidopanel",
|
||||
"settings_panel_title": "Sidopanelens titel",
|
||||
"settings_notifications": "Notifikationer",
|
||||
@@ -864,6 +878,9 @@
|
||||
"battery_fleet_setup_done": "Batteriflotta konfigurerad — en uppgift följer alla batterier.",
|
||||
"update_banner": "En nyare version av Maintenance Supporter finns på servern — ladda om för att uppdatera panelen.",
|
||||
"update_reload": "Ladda om",
|
||||
"row_actions_banner": "Slutför och Hoppa över är nu knappar i uppgiftsraderna. Föredrar du de gamla ikonerna?",
|
||||
"row_actions_keep": "Behåll knappar",
|
||||
"row_actions_back": "Tillbaka till ikoner",
|
||||
"battery_fleet_forecast_overdue": "Förutsagt datum har passerat — batteriet rapporterar fortfarande god status. Om du bytte det, registrera bytet; annars slog prognosen fel.",
|
||||
"cost_from_parts": "Använd ≈ {amount} från delar",
|
||||
"dismiss": "Avfärda",
|
||||
|
||||
@@ -376,6 +376,7 @@
|
||||
"card_title": "Başlık",
|
||||
"card_show_header": "İstatistikli başlığı göster",
|
||||
"card_show_actions": "Eylem düğmelerini göster",
|
||||
"card_action_style": "Tamamla düğmesi stili",
|
||||
"card_compact": "Kompakt mod",
|
||||
"card_max_items": "En fazla öğe (0 = tümü)",
|
||||
"card_filter_status": "Duruma göre filtrele",
|
||||
@@ -450,6 +451,19 @@
|
||||
"feat_checklists_desc": "Görev tamamlama için çok adımlı prosedürler",
|
||||
"settings_general": "Genel",
|
||||
"settings_default_warning": "Varsayılan uyarı günleri",
|
||||
"settings_consumable_threshold": "„Sarf malzemesi düşük“ eşiği (%)",
|
||||
"settings_battery_low_percent": "„Pil zayıf“ eşiği (%)",
|
||||
"settings_thresholds_hint": "Yeni Önerilen kurulumlar (kalan yüzde) ve kendi Battery Notes eşiği olmayan filo pilleri için varsayılanlar. Mevcut görevler değerlerini korur.",
|
||||
"bn_summary": "{name}: {n} pil için % {pct}",
|
||||
"bn_floor_decides": "Senin % {floor} eşiğin hepsi için belirleyici; her pil için iki eşikten yüksek olanı geçerli.",
|
||||
"bn_above_floor": "Bu, senin % {floor} eşiğinin üzerinde; bu yüzden kayıtlı tüm piller için {name} belirleyici.",
|
||||
"bn_overrides": "Kendi eşiği olan {n} cihaz:",
|
||||
"bn_more": "+ {n} tane daha",
|
||||
"settings_row_actions": "Görev satırı eylemleri",
|
||||
"row_actions_buttons_compact": "Düğmeler (telefonda yalnızca simgeler)",
|
||||
"row_actions_buttons": "Metinli düğmeler",
|
||||
"row_actions_icons": "Simgeler (klasik)",
|
||||
"row_actions_follow": "Ev ayarını izle",
|
||||
"settings_panel_enabled": "Kenar çubuğu paneli",
|
||||
"settings_panel_title": "Kenar çubuğu paneli başlığı",
|
||||
"settings_notifications": "Bildirimler",
|
||||
@@ -864,6 +878,9 @@
|
||||
"battery_fleet_setup_done": "Pil filosu kuruldu — tek bir görev tüm pillerinizi takip ediyor.",
|
||||
"update_banner": "Sunucuda Maintenance Supporter'ın daha yeni bir sürümü var — paneli güncellemek için sayfayı yeniden yükleyin.",
|
||||
"update_reload": "Yeniden yükle",
|
||||
"row_actions_banner": "Görev satırlarındaki Tamamla ve Atla artık düğme. Eski simgeleri mi tercih edersiniz?",
|
||||
"row_actions_keep": "Düğmeleri koru",
|
||||
"row_actions_back": "Simgelere geri dön",
|
||||
"battery_fleet_forecast_overdue": "Öngörülen tarih geçti — pil hâlâ sağlıklı görünüyor. Değiştirdiyseniz değişimi kaydedin; aksi halde tahmin yanılmış demektir.",
|
||||
"cost_from_parts": "Parçalardan ≈ {amount} kullan",
|
||||
"dismiss": "Kapat",
|
||||
|
||||
@@ -374,6 +374,7 @@
|
||||
"card_title": "Заголовок",
|
||||
"card_show_header": "Показувати заголовок зі статистикою",
|
||||
"card_show_actions": "Показувати кнопки дій",
|
||||
"card_action_style": "Стиль кнопки «Виконано»",
|
||||
"card_compact": "Компактний режим",
|
||||
"card_max_items": "Макс. елементів (0 = всі)",
|
||||
"card_filter_status": "Фільтрувати за статусом",
|
||||
@@ -448,6 +449,19 @@
|
||||
"feat_checklists_desc": "Багатокрокові процедури для виконання завдань",
|
||||
"settings_general": "Загальне",
|
||||
"settings_default_warning": "Днів попередження за замовчуванням",
|
||||
"settings_consumable_threshold": "Поріг «витратний матеріал закінчується» (%)",
|
||||
"settings_battery_low_percent": "Поріг «батарея розряджена» (%)",
|
||||
"settings_thresholds_hint": "Значення за замовчуванням для нових «Запропонованих налаштувань» (залишок у відсотках) і для батарей парку без власного порогу Battery Notes. Наявні завдання зберігають своє значення.",
|
||||
"bn_summary": "{name}: {pct} % для {n} батарей",
|
||||
"bn_floor_decides": "Ваш поріг {floor} % діє для всіх; для кожної батареї враховується більший із двох порогів.",
|
||||
"bn_above_floor": "Це вище за ваш поріг {floor} %, тому для всіх занотованих батарей вирішує {name}.",
|
||||
"bn_overrides": "{n} пристроїв із власним порогом:",
|
||||
"bn_more": "+ ще {n}",
|
||||
"settings_row_actions": "Дії в рядках завдань",
|
||||
"row_actions_buttons_compact": "Кнопки (на телефоні лише значки)",
|
||||
"row_actions_buttons": "Кнопки з текстом",
|
||||
"row_actions_icons": "Значки (класичний вигляд)",
|
||||
"row_actions_follow": "Як у налаштуваннях дому",
|
||||
"settings_panel_enabled": "Панель у бічному меню",
|
||||
"settings_panel_title": "Заголовок панелі",
|
||||
"settings_notifications": "Сповіщення",
|
||||
@@ -864,6 +878,9 @@
|
||||
"battery_fleet_setup_done": "Парк батарей налаштовано — одне завдання стежить за всіма.",
|
||||
"update_banner": "На сервері новіша версія Maintenance Supporter — перезавантажте, щоб оновити панель.",
|
||||
"update_reload": "Перезавантажити",
|
||||
"row_actions_banner": "«Виконано» та «Пропустити» в рядках завдань тепер кнопки. Бажаєте старі значки?",
|
||||
"row_actions_keep": "Залишити кнопки",
|
||||
"row_actions_back": "Повернути значки",
|
||||
"battery_fleet_forecast_overdue": "Прогнозована дата минула — батарея й далі повідомляє про добрий стан. Якщо ви її замінили, зафіксуйте заміну; інакше прогноз не справдився.",
|
||||
"cost_from_parts": "Узяти ≈ {amount} із запчастин",
|
||||
"dismiss": "Приховати",
|
||||
|
||||
@@ -376,6 +376,7 @@
|
||||
"card_title": "标题",
|
||||
"card_show_header": "显示统计信息页眉",
|
||||
"card_show_actions": "显示操作按钮",
|
||||
"card_action_style": "“完成”按钮样式",
|
||||
"card_compact": "紧凑模式",
|
||||
"card_max_items": "最大显示项 (0 = 全部)",
|
||||
"card_filter_status": "按状态过滤",
|
||||
@@ -450,6 +451,19 @@
|
||||
"feat_checklists_desc": "为任务完成提供多步骤操作流程",
|
||||
"settings_general": "常规",
|
||||
"settings_default_warning": "默认预警天数",
|
||||
"settings_consumable_threshold": "耗材“不足”阈值(%)",
|
||||
"settings_battery_low_percent": "电池“电量低”阈值(%)",
|
||||
"settings_thresholds_hint": "用于新的“建议设置”(剩余百分比)以及没有自身 Battery Notes 阈值的电池组电池。现有任务保留其值。",
|
||||
"bn_summary": "{name}:{n} 块电池为 {pct}%",
|
||||
"bn_floor_decides": "你的 {floor}% 阈值对所有电池生效;每块电池取两个阈值中较高者。",
|
||||
"bn_above_floor": "这高于你的 {floor}% 阈值,因此所有已记录的电池由 {name} 决定。",
|
||||
"bn_overrides": "{n} 台设备有自己的阈值:",
|
||||
"bn_more": "+ 另外 {n} 个",
|
||||
"settings_row_actions": "任务行操作",
|
||||
"row_actions_buttons_compact": "按钮(手机上仅显示图标)",
|
||||
"row_actions_buttons": "带文字的按钮",
|
||||
"row_actions_icons": "图标(经典)",
|
||||
"row_actions_follow": "跟随全局设置",
|
||||
"settings_panel_enabled": "侧边栏面板",
|
||||
"settings_panel_title": "侧边栏面板标题",
|
||||
"settings_notifications": "通知",
|
||||
@@ -864,6 +878,9 @@
|
||||
"battery_fleet_setup_done": "电池群已设置 — 一个任务跟踪所有电池。",
|
||||
"update_banner": "服务器上有更新版本的 Maintenance Supporter——请重新加载以更新面板。",
|
||||
"update_reload": "重新加载",
|
||||
"row_actions_banner": "任务行中的“完成”和“跳过”现在是按钮。更喜欢旧的图标?",
|
||||
"row_actions_keep": "保留按钮",
|
||||
"row_actions_back": "改回图标",
|
||||
"battery_fleet_forecast_overdue": "预测日期已过——电池仍报告状态良好。如果您已更换电池,请记录更换;否则说明预测有误。",
|
||||
"cost_from_parts": "采用配件合计 ≈ {amount}",
|
||||
"dismiss": "忽略",
|
||||
|
||||
@@ -300,6 +300,21 @@ export class MaintenanceSupporterCardEditor extends LitElement {
|
||||
></ha-switch>
|
||||
</ha-formfield>
|
||||
|
||||
<label class="editor-select">
|
||||
<span>${t("card_action_style", L)}</span>
|
||||
<select
|
||||
.value=${this._config.action_style ?? ""}
|
||||
@change=${(e: Event) => {
|
||||
const v = (e.target as HTMLSelectElement).value;
|
||||
this._valueChanged("action_style", v === "" ? undefined : v);
|
||||
}}
|
||||
>
|
||||
<option value="" ?selected=${!this._config.action_style}>${t("row_actions_follow", L)}</option>
|
||||
<option value="buttons" ?selected=${this._config.action_style === "buttons"}>${t("row_actions_buttons", L)}</option>
|
||||
<option value="icons" ?selected=${this._config.action_style === "icons"}>${t("row_actions_icons", L)}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<ha-formfield label="${t("responsible_user", L)}">
|
||||
<ha-switch
|
||||
.checked=${this._config.show_assignee !== false}
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
openCreateObjectDialog,
|
||||
openCreateTaskDialog,
|
||||
openTaskQuickActions,
|
||||
getRowActionStyle,
|
||||
} from "./dialog-mount";
|
||||
|
||||
interface CardDoc {
|
||||
@@ -48,6 +49,8 @@ interface FlatTask {
|
||||
export class MaintenanceSupporterCard extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@state() private _config: CardConfig = { type: "custom:maintenance-supporter-card" };
|
||||
/** #145: global "Task row actions" style, resolved once from the settings cache. */
|
||||
@state() private _globalRowStyle = "buttons_compact";
|
||||
@state() private _objects: MaintenanceObjectResponse[] = [];
|
||||
@state() private _stats: StatisticsResponse | null = null;
|
||||
@state() private _unsub: (() => void) | null = null;
|
||||
@@ -158,6 +161,8 @@ export class MaintenanceSupporterCard extends LitElement {
|
||||
}
|
||||
|
||||
private async _loadData(): Promise<void> {
|
||||
// #145: resolve the household's row-action style once (cached settings).
|
||||
void getRowActionStyle(this.hass).then((s) => { this._globalRowStyle = s; }).catch(() => undefined);
|
||||
try {
|
||||
const [objResult, statsResult] = await Promise.all([
|
||||
this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/objects", compact: true }),
|
||||
@@ -400,6 +405,9 @@ export class MaintenanceSupporterCard extends LitElement {
|
||||
const L = this._lang;
|
||||
const title = this._config.title || t("maintenance", L);
|
||||
const showHeader = this._config.show_header !== false;
|
||||
// #145: the card's own action_style wins; otherwise follow the household
|
||||
// setting (any "buttons*" style → labelled HA button, "icons" → icon).
|
||||
const useButtons = (this._config.action_style ?? (this._globalRowStyle === "icons" ? "icons" : "buttons")) === "buttons";
|
||||
const showActions = this._config.show_actions !== false;
|
||||
const compact = this._config.compact || false;
|
||||
const tasks = this._flatTasks;
|
||||
@@ -507,7 +515,38 @@ export class MaintenanceSupporterCard extends LitElement {
|
||||
? "⚡"
|
||||
: "—"}
|
||||
</div>
|
||||
${showActions
|
||||
${showActions && useButtons
|
||||
? html`
|
||||
<ha-button
|
||||
size="small"
|
||||
appearance="accent"
|
||||
variant="success"
|
||||
class="complete-btn-text"
|
||||
title="${t("complete", L)}"
|
||||
@click=${(e: Event) => {
|
||||
e.stopPropagation();
|
||||
const dlg = this.shadowRoot!.querySelector<MaintenanceCompleteDialog>("maintenance-complete-dialog")!;
|
||||
fillAndOpenCompleteDialog(
|
||||
dlg,
|
||||
buildCompleteDialogArgs({
|
||||
entryId: entry_id,
|
||||
taskId: task.id,
|
||||
taskName: task.name,
|
||||
task,
|
||||
objects: this._objects,
|
||||
lang: L,
|
||||
checklist: task.checklist || [],
|
||||
adaptiveEnabled: !!task.adaptive_config?.enabled,
|
||||
currencySymbol: this._stats?.budget?.currency_symbol || "",
|
||||
}),
|
||||
L,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<ha-icon slot="start" icon="mdi:check"></ha-icon>${t("complete", L)}
|
||||
</ha-button>
|
||||
`
|
||||
: showActions
|
||||
? html`
|
||||
<mwc-icon-button
|
||||
class="complete-btn"
|
||||
@@ -688,6 +727,9 @@ export class MaintenanceSupporterCard extends LitElement {
|
||||
--mdc-icon-size: 18px;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
/* DESIGN PROTOTYPE (#145): action_style: buttons — HA-native labelled button. */
|
||||
.complete-btn-text { --ha-button-font-size: 13px; flex: none; white-space: nowrap; }
|
||||
.complete-btn-text ha-icon { --mdc-icon-size: 18px; }
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Maintenance Supporter Sidebar Panel. */
|
||||
|
||||
import { LitElement, html, nothing } from "lit";
|
||||
import { LitElement, html, nothing, type TemplateResult } from "lit";
|
||||
import { isSafeHttpUrl } from "./helpers/url";
|
||||
import { applySubscriptionEvent, type SubscriptionEvent } from "./helpers/subscription-merge";
|
||||
import { isStaleBundle } from "./helpers/bundle-version";
|
||||
@@ -120,6 +120,12 @@ type GroupByMode = "none" | "area" | "group" | "user";
|
||||
export class MaintenanceSupporterPanel extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@property({ type: Boolean, reflect: true }) public narrow = false;
|
||||
/** #145: the panel itself is tight (< 880 px) although the viewport is not
|
||||
* `narrow` — an iPad in portrait with HA's sidebar docked leaves ~768 px.
|
||||
* The wide row grid then cannot hold labelled action buttons, so the
|
||||
* compact (icon-only) form applies here too. Reflected for the CSS. */
|
||||
@property({ type: Boolean, reflect: true }) public tight = false;
|
||||
private _tightObserver: ResizeObserver | null = null;
|
||||
@property({ attribute: false }) public panel: Record<string, unknown> = {};
|
||||
|
||||
@state() private _objects: MaintenanceObjectResponse[] = [];
|
||||
@@ -170,6 +176,10 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
// value in the task-create dialog so the Settings → General → "Default
|
||||
// warning days" choice actually flows through to new tasks.
|
||||
@state() private _defaultWarningDays = 7;
|
||||
/** #145: global "Task row actions" style (buttons_compact | buttons | icons). */
|
||||
@state() private _rowActionStyle = "buttons_compact";
|
||||
/** #145: one-time "rows look different now" notice for existing installs. */
|
||||
@state() private _rowActionNotice = false;
|
||||
@state() private _actionLoading = false;
|
||||
@state() private _moreMenuOpen = false;
|
||||
@state() private _objMenuOpen = false;
|
||||
@@ -320,6 +330,19 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
else window.setTimeout(kick, 1500);
|
||||
window.addEventListener("popstate", this._popstateHandler);
|
||||
window.addEventListener("keydown", this._paletteKeydown);
|
||||
if (typeof ResizeObserver !== "undefined") {
|
||||
this._tightObserver = new ResizeObserver((entries) => {
|
||||
const w = entries[0]?.contentRect.width ?? 0;
|
||||
// 1000, not 880: between ~880 and ~1000 the wide grid technically
|
||||
// fits its column minimums, but the chips track shrinks below any
|
||||
// readable width — first the chips painted over the type column
|
||||
// (duty-rotation GIF, 2026-08-30), clipped they left ellipsis
|
||||
// fragments. Below ~1000 the narrow layout is the honest choice;
|
||||
// from there up the chips track keeps ≥150 px of slack.
|
||||
if (w > 0) this.tight = w < 1000;
|
||||
});
|
||||
this._tightObserver.observe(this);
|
||||
}
|
||||
window.addEventListener("resize", this._onVirtualScroll, { passive: true });
|
||||
// localStorage.getItem can THROW (Safari private mode / locked-down policies
|
||||
// where storage access raises rather than returning null) — an unguarded
|
||||
@@ -360,6 +383,8 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener("popstate", this._popstateHandler);
|
||||
window.removeEventListener("keydown", this._paletteKeydown);
|
||||
this._tightObserver?.disconnect();
|
||||
this._tightObserver = null;
|
||||
window.removeEventListener("resize", this._onVirtualScroll);
|
||||
this.shadowRoot?.querySelector(".content")?.removeEventListener("scroll", this._onVirtualScroll);
|
||||
this._virtScrollAttached = false;
|
||||
@@ -517,7 +542,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
features: AdvancedFeatures;
|
||||
admin_panel_user_ids?: string[];
|
||||
operator_write_enabled?: boolean;
|
||||
general?: { default_warning_days?: number };
|
||||
general?: { default_warning_days?: number; row_action_style?: string; row_action_notice_pending?: boolean };
|
||||
objects_table_columns?: string[];
|
||||
};
|
||||
this._features = sr.features;
|
||||
@@ -527,6 +552,9 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
if (typeof dwd === "number" && dwd >= 0 && dwd <= 365) {
|
||||
this._defaultWarningDays = dwd;
|
||||
}
|
||||
const ras = sr.general?.row_action_style;
|
||||
this._rowActionStyle = ras === "icons" || ras === "buttons" ? ras : "buttons_compact";
|
||||
this._rowActionNotice = sr.general?.row_action_notice_pending === true;
|
||||
this._objectsTableColumns = sanitizeColumns(sr.objects_table_columns);
|
||||
}
|
||||
|
||||
@@ -1995,6 +2023,20 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
${this._rowActionNotice && !!this.hass?.user?.is_admin
|
||||
? html`
|
||||
<div class="update-banner row-actions-banner" role="status">
|
||||
<ha-icon icon="mdi:gesture-tap-button"></ha-icon>
|
||||
<span>${t("row_actions_banner", this._lang)}</span>
|
||||
<ha-button appearance="plain" @click=${() => this._dismissRowActionNotice(false)}>
|
||||
${t("row_actions_keep", this._lang)}
|
||||
</ha-button>
|
||||
<ha-button appearance="filled" @click=${() => this._dismissRowActionNotice(true)}>
|
||||
${t("row_actions_back", this._lang)}
|
||||
</ha-button>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
${this.narrow || this._view !== "overview" ? this._renderHeader() : nothing}
|
||||
<div class="content">
|
||||
${this._view === "overview"
|
||||
@@ -2285,10 +2327,19 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
<div class="today-task">${row.task_name}</div>
|
||||
<div class="today-object">${row.object_name} · ${formatDueDays(row.days_until_due, L)}</div>
|
||||
</div>
|
||||
<mwc-icon-button class="btn-complete" title="${t("complete", L)}"
|
||||
@click=${(e: Event) => { e.stopPropagation(); this._openCompleteDialogForRow(row); }}>
|
||||
<ha-icon icon="mdi:check"></ha-icon>
|
||||
</mwc-icon-button>
|
||||
${this._actionStyle() === "icons"
|
||||
? html`
|
||||
<mwc-icon-button class="btn-complete" title="${t("complete", L)}"
|
||||
@click=${(e: Event) => { e.stopPropagation(); this._openCompleteDialogForRow(row); }}>
|
||||
<ha-icon icon="mdi:check"></ha-icon>
|
||||
</mwc-icon-button>`
|
||||
: html`
|
||||
<ha-button size="small" appearance="accent" variant="success" class="today-complete" title="${t("complete", L)}"
|
||||
@click=${(e: Event) => { e.stopPropagation(); this._openCompleteDialogForRow(row); }}>
|
||||
${this._actionStyle() === "buttons_compact" && (this.narrow || this.tight)
|
||||
? html`<ha-icon icon="mdi:check"></ha-icon>`
|
||||
: html`<ha-icon slot="start" icon="mdi:check"></ha-icon>${t("complete", L)}`}
|
||||
</ha-button>`}
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
@@ -3215,18 +3266,74 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
: nothing}
|
||||
${renderMiniSparkline(row, this._miniStatsData, this._lang)}
|
||||
</span>
|
||||
<span class="row-actions">
|
||||
<mwc-icon-button class="btn-complete" title="${t("complete", L)}" @click=${(e: Event) => { e.stopPropagation(); this._openCompleteDialogForRow(row); }}>
|
||||
<ha-icon icon="mdi:check"></ha-icon>
|
||||
</mwc-icon-button>
|
||||
<mwc-icon-button class="btn-skip" title="${t("skip", L)}" .disabled=${this._actionLoading} @click=${(e: Event) => { e.stopPropagation(); this._promptSkipTask(row.entry_id, row.task_id); }}>
|
||||
<ha-icon icon="mdi:skip-next"></ha-icon>
|
||||
</mwc-icon-button>
|
||||
</span>
|
||||
${this._renderRowActions(L, () => this._openCompleteDialogForRow(row), () => this._promptSkipTask(row.entry_id, row.task_id))}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/** DESIGN PROTOTYPE (#145 wish 1): row actions as bare icons (current) or
|
||||
* as labelled colour-coded buttons. Switch: localStorage "msp-action-style"
|
||||
* = "buttons" — evaluation only, no settings plumbing yet. */
|
||||
private _actionStyle(): string {
|
||||
return this._rowActionStyle;
|
||||
}
|
||||
|
||||
/** The #145 banner's two ways out: keep the buttons (just dismiss) or go
|
||||
* back to icons (style + dismiss in one write). Admin-only, like every
|
||||
* global setting. */
|
||||
private async _dismissRowActionNotice(backToIcons: boolean): Promise<void> {
|
||||
const settings: Record<string, unknown> = { row_action_notice_pending: false };
|
||||
if (backToIcons) settings.row_action_style = "icons";
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/global/update", settings });
|
||||
this._rowActionNotice = false;
|
||||
if (backToIcons) this._rowActionStyle = "icons";
|
||||
} catch (err) {
|
||||
console.warn("[maintenance-supporter] row-action notice update failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
/** #145: row actions as HA buttons — labelled (accent/success + plain, the
|
||||
* task-detail pairing) or, for "buttons_compact" on narrow screens,
|
||||
* icon-only — or the classic icon pair when the household chose "icons". */
|
||||
private _renderRowActions(L: string, onComplete: () => void, onSkip: () => void): TemplateResult {
|
||||
const style = this._actionStyle();
|
||||
if (style === "buttons" || style === "buttons_compact") {
|
||||
const iconOnly = style === "buttons_compact" && (this.narrow || this.tight);
|
||||
if (iconOnly) {
|
||||
// Compact narrow form: the primary action stays a solid ha-button
|
||||
// (icon only), the secondary one becomes HA's own icon button.
|
||||
return html`
|
||||
<span class="row-actions as-buttons compact">
|
||||
<ha-button size="small" appearance="accent" variant="success" title="${t("complete", L)}" @click=${(e: Event) => { e.stopPropagation(); onComplete(); }}>
|
||||
<ha-icon icon="mdi:check"></ha-icon>
|
||||
</ha-button>
|
||||
<ha-icon-button .label=${t("skip", L)} title="${t("skip", L)}" .disabled=${this._actionLoading} @click=${(e: Event) => { e.stopPropagation(); onSkip(); }}>
|
||||
<ha-icon icon="mdi:skip-next"></ha-icon>
|
||||
</ha-icon-button>
|
||||
</span>`;
|
||||
}
|
||||
return html`
|
||||
<span class="row-actions as-buttons">
|
||||
<ha-button size="small" appearance="accent" variant="success" title="${t("complete", L)}" @click=${(e: Event) => { e.stopPropagation(); onComplete(); }}>
|
||||
<ha-icon slot="start" icon="mdi:check"></ha-icon>${t("complete", L)}
|
||||
</ha-button>
|
||||
<ha-button size="small" appearance="plain" variant="neutral" title="${t("skip", L)}" ?disabled=${this._actionLoading} @click=${(e: Event) => { e.stopPropagation(); onSkip(); }}>
|
||||
<ha-icon slot="start" icon="mdi:skip-next"></ha-icon>${t("skip", L)}
|
||||
</ha-button>
|
||||
</span>`;
|
||||
}
|
||||
return html`
|
||||
<span class="row-actions">
|
||||
<mwc-icon-button class="btn-complete" title="${t("complete", L)}" @click=${(e: Event) => { e.stopPropagation(); onComplete(); }}>
|
||||
<ha-icon icon="mdi:check"></ha-icon>
|
||||
</mwc-icon-button>
|
||||
<mwc-icon-button class="btn-skip" title="${t("skip", L)}" .disabled=${this._actionLoading} @click=${(e: Event) => { e.stopPropagation(); onSkip(); }}>
|
||||
<ha-icon icon="mdi:skip-next"></ha-icon>
|
||||
</mwc-icon-button>
|
||||
</span>`;
|
||||
}
|
||||
|
||||
private _openCompleteDialogForRow(row: TaskRow): void {
|
||||
const obj = this._objects.find(o => o.entry_id === row.entry_id);
|
||||
const task = obj?.tasks.find(t => t.id === row.task_id);
|
||||
@@ -3367,14 +3474,11 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
: nothing}
|
||||
${renderMiniSparkline(task, this._miniStatsData, this._lang)}
|
||||
</span>
|
||||
<span class="row-actions">
|
||||
<mwc-icon-button class="btn-complete" title="${t("complete", L)}" @click=${(e: Event) => { e.stopPropagation(); this._openCompleteDialog(obj.entry_id, task.id, task.name, this._features.checklists ? task.checklist : undefined, this._features.adaptive && !!task.adaptive_config?.enabled); }}>
|
||||
<ha-icon icon="mdi:check"></ha-icon>
|
||||
</mwc-icon-button>
|
||||
<mwc-icon-button class="btn-skip" title="${t("skip", L)}" .disabled=${this._actionLoading} @click=${(e: Event) => { e.stopPropagation(); this._promptSkipTask(obj.entry_id, task.id); }}>
|
||||
<ha-icon icon="mdi:skip-next"></ha-icon>
|
||||
</mwc-icon-button>
|
||||
</span>
|
||||
${this._renderRowActions(
|
||||
L,
|
||||
() => this._openCompleteDialog(obj.entry_id, task.id, task.name, this._features.checklists ? task.checklist : undefined, this._features.adaptive && !!task.adaptive_config?.enabled),
|
||||
() => this._promptSkipTask(obj.entry_id, task.id),
|
||||
)}
|
||||
</div>
|
||||
`)}</div>`}
|
||||
|
||||
|
||||
@@ -287,6 +287,10 @@ export const panelStyles = css`
|
||||
.today-task { font-weight: 600; font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.today-object { color: var(--secondary-text-color); font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.today-row .btn-complete { color: var(--success-color, #4caf50); flex: none; }
|
||||
.today-row .today-complete { flex: none; --ha-button-font-size: 13px; white-space: nowrap; }
|
||||
.today-row .today-complete ha-icon { --mdc-icon-size: 18px; }
|
||||
:host([narrow]) .today-row .today-complete,
|
||||
:host([tight]) .today-row .today-complete { min-width: 0; --ha-button-height: 36px; }
|
||||
.today-empty {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 10px;
|
||||
padding: 48px 16px; color: var(--secondary-text-color); text-align: center;
|
||||
@@ -376,6 +380,17 @@ export const panelStyles = css`
|
||||
color: var(--secondary-text-color);
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
/* The chips track is minmax(0, …): when the row gets squeezed the track
|
||||
may shrink below the chips' natural width — clip instead of painting
|
||||
over the neighbouring type column (duty-rotation GIF, 2026-08-30). */
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
/* …and all-or-nothing: below 60 px of track there is no readable chip,
|
||||
only fragments — hide them entirely via the container query below. */
|
||||
container-type: inline-size;
|
||||
}
|
||||
@container (max-width: 60px) {
|
||||
.sub-chip { display: none; }
|
||||
}
|
||||
/* Empty subline still occupies its grid slot so neighbouring columns line up */
|
||||
.task-sub-empty { min-height: 1px; }
|
||||
@@ -388,6 +403,11 @@ export const panelStyles = css`
|
||||
border-radius: 10px;
|
||||
background: var(--secondary-background-color, rgba(127, 127, 127, 0.1));
|
||||
line-height: 1.4;
|
||||
/* A single chip wider than the squeezed track truncates instead of
|
||||
bleeding into the next column. */
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.sub-chip ha-icon {
|
||||
--mdc-icon-size: 14px;
|
||||
@@ -407,6 +427,16 @@ export const panelStyles = css`
|
||||
--mdc-icon-size: 26px;
|
||||
}
|
||||
|
||||
/* DESIGN PROTOTYPE (#145 wish 1): labelled, colour-coded action buttons
|
||||
instead of bare icons. Complete = filled success pill, Skip = outlined
|
||||
neutral pill; both keep the icon as a leading glyph. */
|
||||
.row-actions.as-buttons { gap: 4px; }
|
||||
.row-actions.as-buttons ha-button {
|
||||
--ha-button-font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.row-actions.as-buttons ha-icon { --mdc-icon-size: 18px; }
|
||||
|
||||
/* Custom elements default to display:inline; the task-detail component
|
||||
renders light-DOM and must behave like the block it wraps. */
|
||||
maintenance-task-detail-view { display: block; }
|
||||
@@ -1440,7 +1470,7 @@ export const panelStyles = css`
|
||||
full bottom row. */
|
||||
display: grid;
|
||||
grid-column: auto;
|
||||
grid-template-columns: auto minmax(0, 1fr) 100px auto;
|
||||
grid-template-columns: auto minmax(76px, 1fr) fit-content(100px) auto; /* name column keeps a word-sized floor, the due column hugs its content — at 400 px the object name used to be clipped mid-word (2026-08-30) */
|
||||
grid-template-rows: auto auto auto;
|
||||
column-gap: 8px;
|
||||
row-gap: 4px;
|
||||
@@ -1480,6 +1510,56 @@ export const panelStyles = css`
|
||||
grid-column: 4;
|
||||
grid-row: 2;
|
||||
}
|
||||
/* Prototype: on phones the two labelled pills stack, so the row keeps its
|
||||
deterministic column X-positions and neither label gets truncated. */
|
||||
/* Labelled buttons stack when the row cannot hold them side by side:
|
||||
narrow viewports AND a tight panel (iPad portrait with the sidebar
|
||||
docked — the viewport is not narrow but the panel is ~768 px). */
|
||||
:host([narrow]) .row-actions.as-buttons,
|
||||
:host([tight]) .row-actions.as-buttons {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 4px;
|
||||
}
|
||||
:host([narrow]) .row-actions.as-buttons ha-button,
|
||||
:host([tight]) .row-actions.as-buttons ha-button { --ha-button-font-size: 12px; }
|
||||
/* buttons_compact: the labelled buttons collapse to an icon-only ha-button
|
||||
plus ha-icon-button side by side — the colour carries the meaning, the
|
||||
row keeps the icon-era height. */
|
||||
:host([narrow]) .row-actions.as-buttons.compact,
|
||||
:host([tight]) .row-actions.as-buttons.compact { flex-direction: row; align-items: center; gap: 2px; }
|
||||
:host([narrow]) .row-actions.compact ha-button,
|
||||
:host([tight]) .row-actions.compact ha-button { --ha-button-height: 36px; min-width: 0; }
|
||||
:host([narrow]) .row-actions.compact ha-icon-button,
|
||||
:host([tight]) .row-actions.compact ha-icon-button { --mdc-icon-button-size: 40px; --mdc-icon-size: 22px; color: var(--secondary-text-color); }
|
||||
:host([narrow]) .row-actions.compact ha-icon,
|
||||
:host([tight]) .row-actions.compact ha-icon { --mdc-icon-size: 20px; }
|
||||
|
||||
/* A TIGHT panel that is not narrow (iPad portrait with HA's sidebar
|
||||
docked: 1024 px viewport, ~768 px panel) gets the narrow row layout too —
|
||||
the wide subgrid overflowed its last column at that width even before
|
||||
the buttons. Same rules as the :host([narrow]) block above / the
|
||||
max-width:768px media mirror below, keyed on the panel's own width. */
|
||||
:host([tight]:not([narrow])) .task-table { display: block; }
|
||||
:host([tight]:not([narrow])) .task-row {
|
||||
display: grid;
|
||||
grid-column: auto;
|
||||
grid-template-columns: auto minmax(76px, 1fr) fit-content(100px) auto; /* name column keeps a word-sized floor, the due column hugs its content — at 400 px the object name used to be clipped mid-word (2026-08-30) */
|
||||
grid-template-rows: auto auto auto;
|
||||
column-gap: 8px;
|
||||
row-gap: 4px;
|
||||
padding: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
:host([tight]:not([narrow])) .cell.type { display: none; }
|
||||
:host([tight]:not([narrow])) .cell.task-name { grid-column: 1 / -1; grid-row: 1; min-width: 0; }
|
||||
:host([tight]:not([narrow])) .cell-badges { grid-column: 1; grid-row: 2; }
|
||||
:host([tight]:not([narrow])) .cell.object-name { grid-column: 2; grid-row: 2; min-width: 0; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; line-height: 1.2; }
|
||||
:host([tight]:not([narrow])) .due-cell { grid-column: 3; grid-row: 2; align-items: flex-end; min-width: 0; }
|
||||
:host([tight]:not([narrow])) .row-actions { grid-column: 4; grid-row: 2; }
|
||||
:host([tight]:not([narrow])) .task-sub { grid-column: 1 / -1; grid-row: 3; font-size: 11px; gap: 6px; justify-content: flex-start; flex-wrap: wrap; }
|
||||
:host([tight]:not([narrow])) .task-sub-empty { display: none; }
|
||||
:host([tight]:not([narrow])) .mini-sparkline { width: 50px; }
|
||||
:host([narrow]) .task-sub {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 3;
|
||||
@@ -1614,7 +1694,7 @@ export const panelStyles = css`
|
||||
/* Mirror the :host([narrow]) grid layout for narrow desktop windows */
|
||||
.task-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) 100px auto;
|
||||
grid-template-columns: auto minmax(76px, 1fr) fit-content(100px) auto; /* name column keeps a word-sized floor, the due column hugs its content — at 400 px the object name used to be clipped mid-word (2026-08-30) */
|
||||
grid-template-rows: auto auto auto;
|
||||
column-gap: 8px;
|
||||
row-gap: 4px;
|
||||
|
||||
@@ -373,6 +373,9 @@ export interface SavedView {
|
||||
export interface CardConfig {
|
||||
type: string;
|
||||
title?: string;
|
||||
/** #145: how the row's Complete action looks. Omit to follow the global
|
||||
* "Task row actions" setting (buttons unless the household chose icons). */
|
||||
action_style?: "icons" | "buttons";
|
||||
show_header?: boolean;
|
||||
max_items?: number;
|
||||
filter_status?: string[];
|
||||
|
||||
@@ -374,6 +374,7 @@
|
||||
"card_title": "Název",
|
||||
"card_show_header": "Zobrazit záhlaví se statistikami",
|
||||
"card_show_actions": "Zobrazit tlačítka akcí",
|
||||
"card_action_style": "Styl tlačítka Dokončit",
|
||||
"card_compact": "Kompaktní režim",
|
||||
"card_max_items": "Max položek (0 = vše)",
|
||||
"card_filter_status": "Filtrovat podle stavu",
|
||||
@@ -448,6 +449,19 @@
|
||||
"feat_checklists_desc": "Vícestupňové procedury pro dokončení úkolu",
|
||||
"settings_general": "Obecné",
|
||||
"settings_default_warning": "Výchozí dny upozornění",
|
||||
"settings_consumable_threshold": "Práh „spotřební materiál nízký“ (%)",
|
||||
"settings_battery_low_percent": "Práh „baterie slabá“ (%)",
|
||||
"settings_thresholds_hint": "Výchozí hodnoty pro nová Navrhovaná nastavení (zbývající procenta) a pro baterie flotily bez vlastního prahu Battery Notes. Stávající úkoly si ponechají svou hodnotu.",
|
||||
"bn_summary": "{name}: {pct} % pro {n} baterií",
|
||||
"bn_floor_decides": "Tvůj práh {floor} % rozhoduje pro všechny; u každé baterie platí vyšší z obou prahů.",
|
||||
"bn_above_floor": "To je nad tvým prahem {floor} %, takže o všech poznamenaných bateriích rozhoduje {name}.",
|
||||
"bn_overrides": "{n} zařízení s vlastním prahem:",
|
||||
"bn_more": "+ {n} dalších",
|
||||
"settings_row_actions": "Akce v řádcích úkolů",
|
||||
"row_actions_buttons_compact": "Tlačítka (na telefonu jen ikony)",
|
||||
"row_actions_buttons": "Tlačítka s textem",
|
||||
"row_actions_icons": "Ikony (klasické)",
|
||||
"row_actions_follow": "Podle nastavení domácnosti",
|
||||
"settings_panel_enabled": "Boční panel",
|
||||
"settings_panel_title": "Název panelu",
|
||||
"settings_notifications": "Oznámení",
|
||||
@@ -864,6 +878,9 @@
|
||||
"battery_fleet_setup_done": "Flotila baterií nastavena — jeden úkol sleduje všechny.",
|
||||
"update_banner": "Na serveru je novější verze Maintenance Supporter — načtěte znovu pro aktualizaci panelu.",
|
||||
"update_reload": "Načíst znovu",
|
||||
"row_actions_banner": "Dokončit a Přeskočit jsou v řádcích úkolů nyní tlačítka. Dáváte přednost starým ikonám?",
|
||||
"row_actions_keep": "Ponechat tlačítka",
|
||||
"row_actions_back": "Zpět na ikony",
|
||||
"battery_fleet_forecast_overdue": "Předpovězené datum uplynulo — baterie stále hlásí dobrý stav. Pokud jste ji vyměnili, zaznamenejte výměnu; jinak byla předpověď mylná.",
|
||||
"cost_from_parts": "Použít ≈ {amount} z dílů",
|
||||
"dismiss": "Skrýt",
|
||||
|
||||
@@ -376,6 +376,7 @@
|
||||
"card_title": "Titel",
|
||||
"card_show_header": "Vis overskrift med statistik",
|
||||
"card_show_actions": "Vis handlingsknapper",
|
||||
"card_action_style": "Stil for Fuldfør-knappen",
|
||||
"card_compact": "Kompakt tilstand",
|
||||
"card_max_items": "Maks. elementer (0 = alle)",
|
||||
"card_filter_status": "Filtrer efter status",
|
||||
@@ -450,6 +451,19 @@
|
||||
"feat_checklists_desc": "Procedurer i flere trin til fuldførelse af opgaver",
|
||||
"settings_general": "Generelt",
|
||||
"settings_default_warning": "Standard advarselsdage",
|
||||
"settings_consumable_threshold": "Tærskel „forbrugsvare lav“ (%)",
|
||||
"settings_battery_low_percent": "Tærskel „batteri lavt“ (%)",
|
||||
"settings_thresholds_hint": "Standardværdier for nye Foreslåede opsætninger (resterende procent) og for flådebatterier uden egen Battery Notes-tærskel. Eksisterende opgaver beholder deres værdi.",
|
||||
"bn_summary": "{name}: {pct} % for {n} batterier",
|
||||
"bn_floor_decides": "Din tærskel på {floor} % afgør for alle; pr. batteri gælder den højeste af de to tærskler.",
|
||||
"bn_above_floor": "Det er over din tærskel på {floor} %, så {name} afgør for alle noterede batterier.",
|
||||
"bn_overrides": "{n} enheder med egen tærskel:",
|
||||
"bn_more": "+ {n} mere",
|
||||
"settings_row_actions": "Handlinger i opgaverækker",
|
||||
"row_actions_buttons_compact": "Knapper (kun ikoner på telefonen)",
|
||||
"row_actions_buttons": "Knapper med tekst",
|
||||
"row_actions_icons": "Ikoner (klassisk)",
|
||||
"row_actions_follow": "Følg husstandens indstilling",
|
||||
"settings_panel_enabled": "Sidepanel",
|
||||
"settings_panel_title": "Sidepanelets titel",
|
||||
"settings_notifications": "Notifikationer",
|
||||
@@ -864,6 +878,9 @@
|
||||
"battery_fleet_setup_done": "Batteriflåde opsat — én opgave følger alle batterier.",
|
||||
"update_banner": "En nyere version af Maintenance Supporter er på serveren — genindlæs for at opdatere panelet.",
|
||||
"update_reload": "Genindlæs",
|
||||
"row_actions_banner": "Fuldfør og Spring over er nu knapper i opgaverækkerne. Foretrækker du de gamle ikoner?",
|
||||
"row_actions_keep": "Behold knapper",
|
||||
"row_actions_back": "Tilbage til ikoner",
|
||||
"battery_fleet_forecast_overdue": "Forudsagt dato er overskredet — batteriet melder stadig god tilstand. Hvis du har skiftet det, registrér udskiftningen; ellers ramte prognosen forbi.",
|
||||
"cost_from_parts": "Brug ≈ {amount} fra dele",
|
||||
"dismiss": "Afvis",
|
||||
|
||||
@@ -376,6 +376,7 @@
|
||||
"card_title": "Titel",
|
||||
"card_show_header": "Kopfzeile mit Statistiken anzeigen",
|
||||
"card_show_actions": "Aktionsbuttons anzeigen",
|
||||
"card_action_style": "Stil des Erledigt-Buttons",
|
||||
"card_compact": "Kompaktmodus",
|
||||
"card_max_items": "Max. Einträge (0 = alle)",
|
||||
"card_filter_status": "Nach Status filtern",
|
||||
@@ -450,6 +451,19 @@
|
||||
"feat_checklists_desc": "Mehrstufige Verfahren zur Aufgabenerlediung",
|
||||
"settings_general": "Allgemein",
|
||||
"settings_default_warning": "Standard-Warntage",
|
||||
"settings_consumable_threshold": "Schwelle „Verbrauchsmaterial niedrig“ (%)",
|
||||
"settings_battery_low_percent": "Schwelle „Batterie niedrig“ (%)",
|
||||
"settings_thresholds_hint": "Vorgaben für neue Vorgeschlagene Einrichtungen (Prozent-Restwert) und für Flotten-Batterien ohne eigene Battery-Notes-Schwelle. Bestehende Aufgaben behalten ihren Wert.",
|
||||
"bn_summary": "{name}: {pct} % für {n} Batterien",
|
||||
"bn_floor_decides": "Deine {floor}-%-Schwelle entscheidet für alle; pro Batterie zählt die höhere der beiden Schwellen.",
|
||||
"bn_above_floor": "Das liegt über deiner {floor}-%-Schwelle — für alle notierten Batterien entscheidet {name}.",
|
||||
"bn_overrides": "{n} Geräte mit eigener Schwelle:",
|
||||
"bn_more": "+ {n} weitere",
|
||||
"settings_row_actions": "Aktionen in Aufgabenzeilen",
|
||||
"row_actions_buttons_compact": "Buttons (auf dem Handy nur Symbole)",
|
||||
"row_actions_buttons": "Buttons mit Text",
|
||||
"row_actions_icons": "Symbole (klassisch)",
|
||||
"row_actions_follow": "Wie in den Einstellungen",
|
||||
"settings_panel_enabled": "Seitenleisten-Panel",
|
||||
"settings_panel_title": "Panel-Titel",
|
||||
"settings_notifications": "Benachrichtigungen",
|
||||
@@ -864,6 +878,9 @@
|
||||
"battery_fleet_setup_done": "Batterie-Flotte eingerichtet — ein Task überwacht alle Batterien.",
|
||||
"update_banner": "Auf dem Server läuft eine neuere Version von Maintenance Supporter — neu laden, um das Panel zu aktualisieren.",
|
||||
"update_reload": "Neu laden",
|
||||
"row_actions_banner": "Erledigt und Überspringen sind in den Aufgabenzeilen jetzt Buttons. Lieber die alten Symbole?",
|
||||
"row_actions_keep": "Buttons behalten",
|
||||
"row_actions_back": "Zurück zu Symbolen",
|
||||
"battery_fleet_forecast_overdue": "Prognosedatum überschritten — die Batterie meldet sich weiterhin gesund. Falls du sie gewechselt hast, trage den Wechsel nach; andernfalls lag die Prognose daneben.",
|
||||
"cost_from_parts": "≈ {amount} aus Teilen übernehmen",
|
||||
"dismiss": "Ausblenden",
|
||||
|
||||
@@ -376,6 +376,7 @@
|
||||
"card_title": "Title",
|
||||
"card_show_header": "Show header with statistics",
|
||||
"card_show_actions": "Show action buttons",
|
||||
"card_action_style": "Complete button style",
|
||||
"card_compact": "Compact mode",
|
||||
"card_max_items": "Max items (0 = all)",
|
||||
"card_filter_status": "Filter by status",
|
||||
@@ -450,6 +451,19 @@
|
||||
"feat_checklists_desc": "Multi-step procedures for task completion",
|
||||
"settings_general": "General",
|
||||
"settings_default_warning": "Default warning days",
|
||||
"settings_consumable_threshold": "Consumable low threshold (%)",
|
||||
"settings_battery_low_percent": "Battery low threshold (%)",
|
||||
"settings_thresholds_hint": "Defaults for new Suggested setups (percent-remaining consumables) and for fleet batteries without their own Battery Notes threshold. Existing tasks keep their value.",
|
||||
"bn_summary": "{name}: {pct} % for {n} batteries",
|
||||
"bn_floor_decides": "Your {floor} % floor decides for all of them; the higher of the two thresholds counts per battery.",
|
||||
"bn_above_floor": "That is above your {floor} % floor, so {name} decides for all noted batteries.",
|
||||
"bn_overrides": "{n} devices with their own threshold:",
|
||||
"bn_more": "+ {n} more",
|
||||
"settings_row_actions": "Task row actions",
|
||||
"row_actions_buttons_compact": "Buttons (icons only on phones)",
|
||||
"row_actions_buttons": "Buttons with text",
|
||||
"row_actions_icons": "Icons (classic)",
|
||||
"row_actions_follow": "Follow the household setting",
|
||||
"settings_panel_enabled": "Sidebar panel",
|
||||
"settings_panel_title": "Sidebar panel title",
|
||||
"settings_notifications": "Notifications",
|
||||
@@ -864,6 +878,9 @@
|
||||
"battery_fleet_setup_done": "Battery fleet set up — one task tracks all your batteries.",
|
||||
"update_banner": "A newer version of Maintenance Supporter is on the server — reload to update the panel.",
|
||||
"update_reload": "Reload",
|
||||
"row_actions_banner": "Complete and Skip in task rows are now buttons. Prefer the old icons?",
|
||||
"row_actions_keep": "Keep buttons",
|
||||
"row_actions_back": "Back to icons",
|
||||
"battery_fleet_forecast_overdue": "Predicted date passed — the battery still reports healthy. If you swapped it, record the replacement; otherwise the forecast was off.",
|
||||
"cost_from_parts": "Use ≈ {amount} from parts",
|
||||
"dismiss": "Dismiss",
|
||||
|
||||
@@ -374,6 +374,7 @@
|
||||
"card_title": "Título",
|
||||
"card_show_header": "Mostrar encabezado con estadísticas",
|
||||
"card_show_actions": "Mostrar botones de acción",
|
||||
"card_action_style": "Estilo del botón Completar",
|
||||
"card_compact": "Modo compacto",
|
||||
"card_max_items": "Máx. elementos (0 = todos)",
|
||||
"card_filter_status": "Filtrar por estado",
|
||||
@@ -448,6 +449,19 @@
|
||||
"feat_checklists_desc": "Procedimientos de varios pasos para completar tareas",
|
||||
"settings_general": "General",
|
||||
"settings_default_warning": "Días de aviso predeterminados",
|
||||
"settings_consumable_threshold": "Umbral „consumible bajo“ (%)",
|
||||
"settings_battery_low_percent": "Umbral „pila baja“ (%)",
|
||||
"settings_thresholds_hint": "Valores predeterminados para nuevas Configuraciones sugeridas (porcentaje restante) y para pilas de la flota sin umbral propio de Battery Notes. Las tareas existentes conservan su valor.",
|
||||
"bn_summary": "{name}: {pct} % para {n} pilas",
|
||||
"bn_floor_decides": "Tu umbral del {floor} % decide para todas; por pila cuenta el mayor de los dos umbrales.",
|
||||
"bn_above_floor": "Está por encima de tu umbral del {floor} %, así que {name} decide para todas las pilas anotadas.",
|
||||
"bn_overrides": "{n} dispositivos con umbral propio:",
|
||||
"bn_more": "+ {n} más",
|
||||
"settings_row_actions": "Acciones en las filas de tareas",
|
||||
"row_actions_buttons_compact": "Botones (solo iconos en el móvil)",
|
||||
"row_actions_buttons": "Botones con texto",
|
||||
"row_actions_icons": "Iconos (clásico)",
|
||||
"row_actions_follow": "Seguir el ajuste del hogar",
|
||||
"settings_panel_enabled": "Panel lateral",
|
||||
"settings_panel_title": "Título del panel lateral",
|
||||
"settings_notifications": "Notificaciones",
|
||||
@@ -864,6 +878,9 @@
|
||||
"battery_fleet_setup_done": "Flota de baterías configurada — una tarea controla todas.",
|
||||
"update_banner": "Hay una versión más reciente de Maintenance Supporter en el servidor — recarga para actualizar el panel.",
|
||||
"update_reload": "Recargar",
|
||||
"row_actions_banner": "Completar y Omitir ahora son botones en las filas de tareas. ¿Prefieres los iconos antiguos?",
|
||||
"row_actions_keep": "Mantener botones",
|
||||
"row_actions_back": "Volver a los iconos",
|
||||
"battery_fleet_forecast_overdue": "Fecha prevista superada: la batería sigue informando buen estado. Si la cambiaste, registra el reemplazo; si no, la previsión falló.",
|
||||
"cost_from_parts": "Usar ≈ {amount} de las piezas",
|
||||
"dismiss": "Descartar",
|
||||
|
||||
@@ -376,6 +376,7 @@
|
||||
"card_title": "Otsikko",
|
||||
"card_show_header": "Näytä otsikko tilastoineen",
|
||||
"card_show_actions": "Näytä toimintopainikkeet",
|
||||
"card_action_style": "Valmis-painikkeen tyyli",
|
||||
"card_compact": "Tiivis tila",
|
||||
"card_max_items": "Enimmäismäärä (0 = kaikki)",
|
||||
"card_filter_status": "Suodata tilan mukaan",
|
||||
@@ -450,6 +451,19 @@
|
||||
"feat_checklists_desc": "Monivaiheiset menettelyt tehtävän suorittamiseen",
|
||||
"settings_general": "Yleiset",
|
||||
"settings_default_warning": "Oletusvaroituspäivät",
|
||||
"settings_consumable_threshold": "Kynnys „kulutustarvike vähissä“ (%)",
|
||||
"settings_battery_low_percent": "Kynnys „paristo vähissä“ (%)",
|
||||
"settings_thresholds_hint": "Oletukset uusille Ehdotetuille asetuksille (jäljellä oleva prosentti) ja paristokannan paristoille ilman omaa Battery Notes -kynnystä. Olemassa olevat tehtävät säilyttävät arvonsa.",
|
||||
"bn_summary": "{name}: {pct} % {n} paristolle",
|
||||
"bn_floor_decides": "Sinun {floor} %:n kynnyksesi ratkaisee kaikille; paristoa kohden pätee korkeampi kahdesta kynnyksestä.",
|
||||
"bn_above_floor": "Se on sinun {floor} %:n kynnyksesi yläpuolella, joten {name} ratkaisee kaikille kirjatuille paristoille.",
|
||||
"bn_overrides": "{n} laitetta, joilla on oma kynnys:",
|
||||
"bn_more": "+ {n} lisää",
|
||||
"settings_row_actions": "Tehtävärivien toiminnot",
|
||||
"row_actions_buttons_compact": "Painikkeet (puhelimessa vain kuvakkeet)",
|
||||
"row_actions_buttons": "Painikkeet tekstillä",
|
||||
"row_actions_icons": "Kuvakkeet (perinteinen)",
|
||||
"row_actions_follow": "Kotitalouden asetuksen mukaan",
|
||||
"settings_panel_enabled": "Sivupalkin paneeli",
|
||||
"settings_panel_title": "Sivupalkin paneelin otsikko",
|
||||
"settings_notifications": "Ilmoitukset",
|
||||
@@ -864,6 +878,9 @@
|
||||
"battery_fleet_setup_done": "Paristokanta määritetty — yksi tehtävä seuraa kaikkia.",
|
||||
"update_banner": "Palvelimella on uudempi Maintenance Supporter -versio — lataa uudelleen päivittääksesi paneelin.",
|
||||
"update_reload": "Lataa uudelleen",
|
||||
"row_actions_banner": "Valmis ja Ohita ovat tehtäväriveillä nyt painikkeita. Haluatko mieluummin vanhat kuvakkeet?",
|
||||
"row_actions_keep": "Pidä painikkeet",
|
||||
"row_actions_back": "Takaisin kuvakkeisiin",
|
||||
"battery_fleet_forecast_overdue": "Ennustettu päivä on ohitettu — akku ilmoittaa yhä hyvästä kunnosta. Jos vaihdoit sen, kirjaa vaihto; muuten ennuste oli pielessä.",
|
||||
"cost_from_parts": "Käytä ≈ {amount} osista",
|
||||
"dismiss": "Hylkää",
|
||||
|
||||
@@ -374,6 +374,7 @@
|
||||
"card_title": "Titre",
|
||||
"card_show_header": "Afficher l'en-tête avec statistiques",
|
||||
"card_show_actions": "Afficher les boutons d'action",
|
||||
"card_action_style": "Style du bouton Terminer",
|
||||
"card_compact": "Mode compact",
|
||||
"card_max_items": "Nombre max (0 = tous)",
|
||||
"card_filter_status": "Filtrer par statut",
|
||||
@@ -448,6 +449,19 @@
|
||||
"feat_checklists_desc": "Procédures multi-étapes pour la réalisation des tâches",
|
||||
"settings_general": "Général",
|
||||
"settings_default_warning": "Jours d'avertissement par défaut",
|
||||
"settings_consumable_threshold": "Seuil « consommable bas » (%)",
|
||||
"settings_battery_low_percent": "Seuil « pile faible » (%)",
|
||||
"settings_thresholds_hint": "Valeurs par défaut pour les nouvelles Configurations suggérées (pourcentage restant) et pour les piles de la flotte sans seuil Battery Notes propre. Les tâches existantes gardent leur valeur.",
|
||||
"bn_summary": "{name} : {pct} % pour {n} piles",
|
||||
"bn_floor_decides": "Votre seuil de {floor} % décide pour toutes ; par pile, c'est le plus élevé des deux seuils qui compte.",
|
||||
"bn_above_floor": "C'est au-dessus de votre seuil de {floor} % : {name} décide donc pour toutes les piles notées.",
|
||||
"bn_overrides": "{n} appareils avec leur propre seuil :",
|
||||
"bn_more": "+ {n} autres",
|
||||
"settings_row_actions": "Actions des lignes de tâches",
|
||||
"row_actions_buttons_compact": "Boutons (icônes seules sur téléphone)",
|
||||
"row_actions_buttons": "Boutons avec texte",
|
||||
"row_actions_icons": "Icônes (classique)",
|
||||
"row_actions_follow": "Suivre le réglage du foyer",
|
||||
"settings_panel_enabled": "Panneau latéral",
|
||||
"settings_panel_title": "Titre du panneau latéral",
|
||||
"settings_notifications": "Notifications",
|
||||
@@ -864,6 +878,9 @@
|
||||
"battery_fleet_setup_done": "Parc de piles configuré — une tâche suit toutes vos piles.",
|
||||
"update_banner": "Une version plus récente de Maintenance Supporter est sur le serveur — rechargez pour mettre à jour le panneau.",
|
||||
"update_reload": "Recharger",
|
||||
"row_actions_banner": "Terminer et Ignorer sont désormais des boutons dans les lignes de tâches. Vous préférez les anciennes icônes ?",
|
||||
"row_actions_keep": "Garder les boutons",
|
||||
"row_actions_back": "Revenir aux icônes",
|
||||
"battery_fleet_forecast_overdue": "Date prévue dépassée — la batterie se signale toujours en bon état. Si vous l'avez remplacée, enregistrez le remplacement ; sinon la prévision était erronée.",
|
||||
"cost_from_parts": "Reprendre ≈ {amount} des pièces",
|
||||
"dismiss": "Ignorer",
|
||||
|
||||
@@ -376,6 +376,7 @@
|
||||
"card_title": "शीर्षक",
|
||||
"card_show_header": "सांख्यिकी के साथ हेडर दिखाएँ",
|
||||
"card_show_actions": "क्रिया बटन दिखाएँ",
|
||||
"card_action_style": "पूर्ण बटन की शैली",
|
||||
"card_compact": "संक्षिप्त मोड",
|
||||
"card_max_items": "अधिकतम आइटम (0 = सभी)",
|
||||
"card_filter_status": "स्थिति के अनुसार फ़िल्टर करें",
|
||||
@@ -450,6 +451,19 @@
|
||||
"feat_checklists_desc": "कार्य पूर्णता के लिए बहु-चरणीय प्रक्रियाएँ",
|
||||
"settings_general": "सामान्य",
|
||||
"settings_default_warning": "डिफ़ॉल्ट चेतावनी दिन",
|
||||
"settings_consumable_threshold": "उपभोग्य „कम“ सीमा (%)",
|
||||
"settings_battery_low_percent": "बैटरी „कम“ सीमा (%)",
|
||||
"settings_thresholds_hint": "नए सुझाए गए सेटअप (शेष प्रतिशत) और अपनी Battery Notes सीमा न रखने वाली बेड़े की बैटरियों के लिए डिफ़ॉल्ट। मौजूदा कार्य अपना मान रखते हैं।",
|
||||
"bn_summary": "{name}: {n} बैटरियों के लिए {pct} %",
|
||||
"bn_floor_decides": "आपकी {floor} % सीमा सभी के लिए निर्णायक है; प्रत्येक बैटरी के लिए दोनों में से ऊँची सीमा मान्य है।",
|
||||
"bn_above_floor": "यह आपकी {floor} % सीमा से ऊपर है, इसलिए सभी नोट की गई बैटरियों के लिए {name} निर्णायक है।",
|
||||
"bn_overrides": "{n} डिवाइस अपनी सीमा के साथ:",
|
||||
"bn_more": "+ {n} और",
|
||||
"settings_row_actions": "कार्य पंक्ति क्रियाएँ",
|
||||
"row_actions_buttons_compact": "बटन (फ़ोन पर केवल आइकन)",
|
||||
"row_actions_buttons": "टेक्स्ट वाले बटन",
|
||||
"row_actions_icons": "आइकन (क्लासिक)",
|
||||
"row_actions_follow": "घर की सेटिंग के अनुसार",
|
||||
"settings_panel_enabled": "साइडबार पैनल",
|
||||
"settings_panel_title": "साइडबार पैनल शीर्षक",
|
||||
"settings_notifications": "सूचनाएँ",
|
||||
@@ -864,6 +878,9 @@
|
||||
"battery_fleet_setup_done": "बैटरी फ्लीट सेट — एक कार्य सभी बैटरियों को ट्रैक करता है।",
|
||||
"update_banner": "सर्वर पर Maintenance Supporter का नया संस्करण है — पैनल अपडेट करने के लिए पुनः लोड करें।",
|
||||
"update_reload": "पुनः लोड करें",
|
||||
"row_actions_banner": "कार्य पंक्तियों में पूर्ण और छोड़ें अब बटन हैं। पुराने आइकन पसंद हैं?",
|
||||
"row_actions_keep": "बटन रखें",
|
||||
"row_actions_back": "आइकन पर वापस जाएँ",
|
||||
"battery_fleet_forecast_overdue": "अनुमानित तिथि बीत गई — बैटरी अब भी अच्छी स्थिति बता रही है। यदि आपने इसे बदला है, तो बदलाव दर्ज करें; अन्यथा पूर्वानुमान गलत था।",
|
||||
"cost_from_parts": "पुर्ज़ों से ≈ {amount} लें",
|
||||
"dismiss": "हटाएँ",
|
||||
|
||||
@@ -376,6 +376,7 @@
|
||||
"card_title": "Cím",
|
||||
"card_show_header": "Fejléc megjelenítése statisztikákkal",
|
||||
"card_show_actions": "Műveletgombok megjelenítése",
|
||||
"card_action_style": "A Kész gomb stílusa",
|
||||
"card_compact": "Kompakt mód",
|
||||
"card_max_items": "Max. elemszám (0 = mind)",
|
||||
"card_filter_status": "Szűrés állapot szerint",
|
||||
@@ -450,6 +451,19 @@
|
||||
"feat_checklists_desc": "Többlépéses eljárások a feladatok elvégzéséhez",
|
||||
"settings_general": "Általános",
|
||||
"settings_default_warning": "Alapértelmezett figyelmeztetési napok",
|
||||
"settings_consumable_threshold": "„Fogyóeszköz alacsony” küszöb (%)",
|
||||
"settings_battery_low_percent": "„Alacsony akkumulátor” küszöb (%)",
|
||||
"settings_thresholds_hint": "Alapértékek az új Javasolt beállításokhoz (százalékos maradék) és a saját Battery Notes-küszöb nélküli flottaelemekhez. A meglévő feladatok megtartják az értéküket.",
|
||||
"bn_summary": "{name}: {pct} % {n} elemhez",
|
||||
"bn_floor_decides": "A te {floor} %-os küszöböd dönt mindegyiknél; elemenként a két küszöb közül a magasabb számít.",
|
||||
"bn_above_floor": "Ez a te {floor} %-os küszöböd felett van, így minden feljegyzett elemnél a {name} dönt.",
|
||||
"bn_overrides": "{n} eszköz saját küszöbbel:",
|
||||
"bn_more": "+ még {n}",
|
||||
"settings_row_actions": "Műveletek a feladatsorokban",
|
||||
"row_actions_buttons_compact": "Gombok (telefonon csak ikonok)",
|
||||
"row_actions_buttons": "Gombok szöveggel",
|
||||
"row_actions_icons": "Ikonok (klasszikus)",
|
||||
"row_actions_follow": "A háztartás beállítása szerint",
|
||||
"settings_panel_enabled": "Oldalsáv-panel",
|
||||
"settings_panel_title": "Oldalsáv-panel címe",
|
||||
"settings_notifications": "Értesítések",
|
||||
@@ -864,6 +878,9 @@
|
||||
"battery_fleet_setup_done": "Elemflotta beállítva — egyetlen feladat követi az összes elemet.",
|
||||
"update_banner": "A Maintenance Supporter újabb verziója érhető el a szerveren — töltse újra az oldalt a panel frissítéséhez.",
|
||||
"update_reload": "Újratöltés",
|
||||
"row_actions_banner": "A Kész és a Kihagyás a feladatsorokban mostantól gombok. Inkább a régi ikonokat szeretné?",
|
||||
"row_actions_keep": "Gombok megtartása",
|
||||
"row_actions_back": "Vissza az ikonokhoz",
|
||||
"battery_fleet_forecast_overdue": "Az előrejelzett dátum elmúlt — az elem továbbra is jó állapotot jelez. Ha kicserélted, rögzítsd a cserét; különben az előrejelzés tévedett.",
|
||||
"cost_from_parts": "≈ {amount} átvétele az alkatrészekből",
|
||||
"dismiss": "Elrejtés",
|
||||
|
||||
@@ -374,6 +374,7 @@
|
||||
"card_title": "Titolo",
|
||||
"card_show_header": "Mostra intestazione con statistiche",
|
||||
"card_show_actions": "Mostra pulsanti azione",
|
||||
"card_action_style": "Stile del pulsante Completa",
|
||||
"card_compact": "Modalità compatta",
|
||||
"card_max_items": "Max elementi (0 = tutti)",
|
||||
"card_filter_status": "Filtra per stato",
|
||||
@@ -448,6 +449,19 @@
|
||||
"feat_checklists_desc": "Procedure multi-fase per il completamento delle attività",
|
||||
"settings_general": "Generale",
|
||||
"settings_default_warning": "Giorni di avviso predefiniti",
|
||||
"settings_consumable_threshold": "Soglia „consumabile basso“ (%)",
|
||||
"settings_battery_low_percent": "Soglia „batteria scarica“ (%)",
|
||||
"settings_thresholds_hint": "Valori predefiniti per le nuove Configurazioni suggerite (percentuale residua) e per le batterie della flotta senza soglia Battery Notes propria. Le attività esistenti mantengono il loro valore.",
|
||||
"bn_summary": "{name}: {pct} % per {n} batterie",
|
||||
"bn_floor_decides": "La tua soglia del {floor} % decide per tutte; per ogni batteria conta la più alta delle due soglie.",
|
||||
"bn_above_floor": "È sopra la tua soglia del {floor} %, quindi {name} decide per tutte le batterie annotate.",
|
||||
"bn_overrides": "{n} dispositivi con soglia propria:",
|
||||
"bn_more": "+ altri {n}",
|
||||
"settings_row_actions": "Azioni nelle righe delle attività",
|
||||
"row_actions_buttons_compact": "Pulsanti (solo icone sul telefono)",
|
||||
"row_actions_buttons": "Pulsanti con testo",
|
||||
"row_actions_icons": "Icone (classico)",
|
||||
"row_actions_follow": "Segui l'impostazione della casa",
|
||||
"settings_panel_enabled": "Pannello laterale",
|
||||
"settings_panel_title": "Titolo pannello laterale",
|
||||
"settings_notifications": "Notifiche",
|
||||
@@ -864,6 +878,9 @@
|
||||
"battery_fleet_setup_done": "Parco batterie configurato — un'attività monitora tutte.",
|
||||
"update_banner": "Sul server è presente una versione più recente di Maintenance Supporter — ricarica per aggiornare il pannello.",
|
||||
"update_reload": "Ricarica",
|
||||
"row_actions_banner": "Completa e Salta nelle righe delle attività ora sono pulsanti. Preferisci le vecchie icone?",
|
||||
"row_actions_keep": "Mantieni i pulsanti",
|
||||
"row_actions_back": "Torna alle icone",
|
||||
"battery_fleet_forecast_overdue": "Data prevista superata — la batteria risulta ancora in buono stato. Se l'hai sostituita, registra la sostituzione; altrimenti la previsione era errata.",
|
||||
"cost_from_parts": "Usa ≈ {amount} dai ricambi",
|
||||
"dismiss": "Ignora",
|
||||
|
||||
@@ -376,6 +376,7 @@
|
||||
"card_title": "タイトル",
|
||||
"card_show_header": "統計付きヘッダーを表示",
|
||||
"card_show_actions": "操作ボタンを表示",
|
||||
"card_action_style": "完了ボタンのスタイル",
|
||||
"card_compact": "コンパクトモード",
|
||||
"card_max_items": "最大表示件数(0=すべて)",
|
||||
"card_filter_status": "状態で絞り込み",
|
||||
@@ -450,6 +451,19 @@
|
||||
"feat_checklists_desc": "タスク完了のための複数手順の手続き",
|
||||
"settings_general": "一般",
|
||||
"settings_default_warning": "既定の警告日数",
|
||||
"settings_consumable_threshold": "消耗品「残量少」しきい値(%)",
|
||||
"settings_battery_low_percent": "電池「残量少」しきい値(%)",
|
||||
"settings_thresholds_hint": "新しい「推奨セットアップ」(残量パーセント)と、独自の Battery Notes しきい値を持たないバッテリー一覧の電池に適用される既定値です。既存のタスクは値を保持します。",
|
||||
"bn_summary": "{name}:{n} 個の電池に {pct} %",
|
||||
"bn_floor_decides": "あなたの {floor} % のしきい値がすべてに適用されます。電池ごとに高い方のしきい値が有効です。",
|
||||
"bn_above_floor": "これはあなたの {floor} % のしきい値を上回るため、記録済みのすべての電池は {name} が決定します。",
|
||||
"bn_overrides": "独自のしきい値を持つデバイス {n} 台:",
|
||||
"bn_more": "+ 他 {n} 件",
|
||||
"settings_row_actions": "タスク行の操作",
|
||||
"row_actions_buttons_compact": "ボタン(スマートフォンではアイコンのみ)",
|
||||
"row_actions_buttons": "テキスト付きボタン",
|
||||
"row_actions_icons": "アイコン(従来)",
|
||||
"row_actions_follow": "全体設定に従う",
|
||||
"settings_panel_enabled": "サイドバーパネル",
|
||||
"settings_panel_title": "サイドバーパネルのタイトル",
|
||||
"settings_notifications": "通知",
|
||||
@@ -864,6 +878,9 @@
|
||||
"battery_fleet_setup_done": "電池フリートを設定 — 1つのタスクで全電池を管理。",
|
||||
"update_banner": "サーバーに新しいバージョンの Maintenance Supporter があります — 再読み込みしてパネルを更新してください。",
|
||||
"update_reload": "再読み込み",
|
||||
"row_actions_banner": "タスク行の「完了」と「スキップ」がボタンになりました。以前のアイコンに戻しますか?",
|
||||
"row_actions_keep": "ボタンのままにする",
|
||||
"row_actions_back": "アイコンに戻す",
|
||||
"battery_fleet_forecast_overdue": "予測日を過ぎましたが、電池はまだ正常と報告しています。交換済みなら交換を記録してください。そうでなければ予測が外れています。",
|
||||
"cost_from_parts": "部品から ≈ {amount} を適用",
|
||||
"dismiss": "閉じる",
|
||||
|
||||
@@ -376,6 +376,7 @@
|
||||
"card_title": "제목",
|
||||
"card_show_header": "통계 헤더 표시",
|
||||
"card_show_actions": "동작 버튼 표시",
|
||||
"card_action_style": "완료 버튼 스타일",
|
||||
"card_compact": "컴팩트 모드",
|
||||
"card_max_items": "최대 항목 수 (0 = 전체)",
|
||||
"card_filter_status": "상태로 필터",
|
||||
@@ -450,6 +451,19 @@
|
||||
"feat_checklists_desc": "작업 완료를 위한 다단계 절차",
|
||||
"settings_general": "일반",
|
||||
"settings_default_warning": "기본 경고 일수",
|
||||
"settings_consumable_threshold": "소모품 „부족“ 임계값 (%)",
|
||||
"settings_battery_low_percent": "배터리 „부족“ 임계값 (%)",
|
||||
"settings_thresholds_hint": "새 추천 설정(남은 비율)과 자체 Battery Notes 임계값이 없는 배터리 목록 배터리의 기본값입니다. 기존 작업은 값을 유지합니다.",
|
||||
"bn_summary": "{name}: 배터리 {n}개에 {pct} %",
|
||||
"bn_floor_decides": "당신의 {floor} % 임계값이 모두에 적용됩니다. 배터리마다 두 임계값 중 높은 쪽이 적용됩니다.",
|
||||
"bn_above_floor": "이는 당신의 {floor} % 임계값보다 높으므로, 기록된 모든 배터리는 {name}이(가) 결정합니다.",
|
||||
"bn_overrides": "자체 임계값이 있는 기기 {n}개:",
|
||||
"bn_more": "+ {n}개 더",
|
||||
"settings_row_actions": "작업 행 동작",
|
||||
"row_actions_buttons_compact": "버튼 (휴대폰에서는 아이콘만)",
|
||||
"row_actions_buttons": "텍스트가 있는 버튼",
|
||||
"row_actions_icons": "아이콘 (기본)",
|
||||
"row_actions_follow": "가정 설정을 따름",
|
||||
"settings_panel_enabled": "사이드바 패널",
|
||||
"settings_panel_title": "사이드바 패널 제목",
|
||||
"settings_notifications": "알림",
|
||||
@@ -864,6 +878,9 @@
|
||||
"battery_fleet_setup_done": "배터리 플릿이 설정되었습니다 — 하나의 작업이 모든 배터리를 추적합니다.",
|
||||
"update_banner": "서버에 Maintenance Supporter의 새 버전이 있습니다 — 패널을 업데이트하려면 새로 고침하세요.",
|
||||
"update_reload": "새로 고침",
|
||||
"row_actions_banner": "작업 행의 완료와 건너뛰기가 이제 버튼으로 표시됩니다. 이전 아이콘이 더 좋으신가요?",
|
||||
"row_actions_keep": "버튼 유지",
|
||||
"row_actions_back": "아이콘으로 되돌리기",
|
||||
"battery_fleet_forecast_overdue": "예측 날짜가 지났지만 배터리는 여전히 정상으로 보고됩니다. 교체했다면 교체를 기록하세요. 아니라면 예측이 빗나간 것입니다.",
|
||||
"cost_from_parts": "부품에서 ≈ {amount} 적용",
|
||||
"dismiss": "닫기",
|
||||
|
||||
@@ -376,6 +376,7 @@
|
||||
"card_title": "Tittel",
|
||||
"card_show_header": "Vis topptekst med statistikk",
|
||||
"card_show_actions": "Vis handlingsknapper",
|
||||
"card_action_style": "Stil på Fullfør-knappen",
|
||||
"card_compact": "Kompakt modus",
|
||||
"card_max_items": "Maks elementer (0 = alle)",
|
||||
"card_filter_status": "Filtrer etter status",
|
||||
@@ -450,6 +451,19 @@
|
||||
"feat_checklists_desc": "Flertrinnsprosedyrer for fullføring av oppgaver",
|
||||
"settings_general": "Generelt",
|
||||
"settings_default_warning": "Standard varslingsdager",
|
||||
"settings_consumable_threshold": "Terskel „forbruksvare lav“ (%)",
|
||||
"settings_battery_low_percent": "Terskel „batteri lavt“ (%)",
|
||||
"settings_thresholds_hint": "Standardverdier for nye Foreslåtte oppsett (gjenværende prosent) og for flåtebatterier uten egen Battery Notes-terskel. Eksisterende oppgaver beholder verdien sin.",
|
||||
"bn_summary": "{name}: {pct} % for {n} batterier",
|
||||
"bn_floor_decides": "Din terskel på {floor} % avgjør for alle; per batteri gjelder den høyeste av de to tersklene.",
|
||||
"bn_above_floor": "Det er over din terskel på {floor} %, så {name} avgjør for alle noterte batterier.",
|
||||
"bn_overrides": "{n} enheter med egen terskel:",
|
||||
"bn_more": "+ {n} til",
|
||||
"settings_row_actions": "Handlinger i oppgaverader",
|
||||
"row_actions_buttons_compact": "Knapper (bare ikoner på mobil)",
|
||||
"row_actions_buttons": "Knapper med tekst",
|
||||
"row_actions_icons": "Ikoner (klassisk)",
|
||||
"row_actions_follow": "Følg husstandens innstilling",
|
||||
"settings_panel_enabled": "Sidepanel",
|
||||
"settings_panel_title": "Tittel på sidepanel",
|
||||
"settings_notifications": "Varsler",
|
||||
@@ -864,6 +878,9 @@
|
||||
"battery_fleet_setup_done": "Batteriflåte satt opp — én oppgave følger alle batteriene.",
|
||||
"update_banner": "En nyere versjon av Maintenance Supporter er på serveren — last inn på nytt for å oppdatere panelet.",
|
||||
"update_reload": "Last inn på nytt",
|
||||
"row_actions_banner": "Fullfør og Hopp over er nå knapper i oppgaveradene. Foretrekker du de gamle ikonene?",
|
||||
"row_actions_keep": "Behold knapper",
|
||||
"row_actions_back": "Tilbake til ikoner",
|
||||
"battery_fleet_forecast_overdue": "Forutsagt dato er passert — batteriet melder fortsatt god tilstand. Hvis du byttet det, registrer byttet; ellers bommet prognosen.",
|
||||
"cost_from_parts": "Bruk ≈ {amount} fra deler",
|
||||
"dismiss": "Avvis",
|
||||
|
||||
@@ -374,6 +374,7 @@
|
||||
"card_title": "Titel",
|
||||
"card_show_header": "Koptekst met statistieken tonen",
|
||||
"card_show_actions": "Actieknoppen tonen",
|
||||
"card_action_style": "Stijl van de Voltooien-knop",
|
||||
"card_compact": "Compacte modus",
|
||||
"card_max_items": "Max items (0 = alle)",
|
||||
"card_filter_status": "Filteren op status",
|
||||
@@ -448,6 +449,19 @@
|
||||
"feat_checklists_desc": "Meerstaps procedures voor taakvoltooiing",
|
||||
"settings_general": "Algemeen",
|
||||
"settings_default_warning": "Standaard waarschuwingsdagen",
|
||||
"settings_consumable_threshold": "Drempel „verbruiksartikel laag“ (%)",
|
||||
"settings_battery_low_percent": "Drempel „batterij laag“ (%)",
|
||||
"settings_thresholds_hint": "Standaardwaarden voor nieuwe Voorgestelde installaties (resterend percentage) en voor vlootbatterijen zonder eigen Battery Notes-drempel. Bestaande taken behouden hun waarde.",
|
||||
"bn_summary": "{name}: {pct} % voor {n} batterijen",
|
||||
"bn_floor_decides": "Jouw drempel van {floor} % beslist voor allemaal; per batterij telt de hoogste van de twee drempels.",
|
||||
"bn_above_floor": "Dat ligt boven jouw drempel van {floor} %, dus {name} beslist voor alle genoteerde batterijen.",
|
||||
"bn_overrides": "{n} apparaten met een eigen drempel:",
|
||||
"bn_more": "+ {n} meer",
|
||||
"settings_row_actions": "Acties in taakrijen",
|
||||
"row_actions_buttons_compact": "Knoppen (op telefoons alleen pictogrammen)",
|
||||
"row_actions_buttons": "Knoppen met tekst",
|
||||
"row_actions_icons": "Pictogrammen (klassiek)",
|
||||
"row_actions_follow": "Volg de instelling van het huishouden",
|
||||
"settings_panel_enabled": "Zijbalkpaneel",
|
||||
"settings_panel_title": "Titel zijbalkpaneel",
|
||||
"settings_notifications": "Meldingen",
|
||||
@@ -864,6 +878,9 @@
|
||||
"battery_fleet_setup_done": "Batterijvloot ingesteld — één taak volgt al je batterijen.",
|
||||
"update_banner": "Er staat een nieuwere versie van Maintenance Supporter op de server — herlaad om het paneel bij te werken.",
|
||||
"update_reload": "Herladen",
|
||||
"row_actions_banner": "Voltooien en Overslaan zijn in taakrijen nu knoppen. Liever de oude pictogrammen?",
|
||||
"row_actions_keep": "Knoppen houden",
|
||||
"row_actions_back": "Terug naar pictogrammen",
|
||||
"battery_fleet_forecast_overdue": "Voorspelde datum verstreken — de batterij meldt zich nog steeds gezond. Heb je hem vervangen, registreer dan de vervanging; anders zat de voorspelling ernaast.",
|
||||
"cost_from_parts": "≈ {amount} uit onderdelen overnemen",
|
||||
"dismiss": "Verbergen",
|
||||
|
||||
@@ -374,6 +374,7 @@
|
||||
"card_title": "Tytuł",
|
||||
"card_show_header": "Pokaż nagłówek ze statystykami",
|
||||
"card_show_actions": "Pokaż przyciski akcji",
|
||||
"card_action_style": "Styl przycisku Ukończ",
|
||||
"card_compact": "Tryb kompaktowy",
|
||||
"card_max_items": "Maks. elementów (0 = wszystkie)",
|
||||
"card_filter_status": "Filtruj wg statusu",
|
||||
@@ -448,6 +449,19 @@
|
||||
"feat_checklists_desc": "Wieloetapowe procedury wykonania zadania",
|
||||
"settings_general": "Ogólne",
|
||||
"settings_default_warning": "Domyślne dni ostrzeżenia",
|
||||
"settings_consumable_threshold": "Próg „materiał eksploatacyjny niski” (%)",
|
||||
"settings_battery_low_percent": "Próg „bateria słaba” (%)",
|
||||
"settings_thresholds_hint": "Wartości domyślne dla nowych Sugerowanych konfiguracji (procent pozostały) i dla baterii floty bez własnego progu Battery Notes. Istniejące zadania zachowują swoją wartość.",
|
||||
"bn_summary": "{name}: {pct} % dla {n} baterii",
|
||||
"bn_floor_decides": "Twój próg {floor} % decyduje dla wszystkich; dla każdej baterii liczy się wyższy z dwóch progów.",
|
||||
"bn_above_floor": "To powyżej twojego progu {floor} %, więc o wszystkich odnotowanych bateriach decyduje {name}.",
|
||||
"bn_overrides": "{n} urządzeń z własnym progiem:",
|
||||
"bn_more": "+ {n} więcej",
|
||||
"settings_row_actions": "Akcje w wierszach zadań",
|
||||
"row_actions_buttons_compact": "Przyciski (na telefonie tylko ikony)",
|
||||
"row_actions_buttons": "Przyciski z tekstem",
|
||||
"row_actions_icons": "Ikony (klasyczne)",
|
||||
"row_actions_follow": "Zgodnie z ustawieniem domu",
|
||||
"settings_panel_enabled": "Panel boczny",
|
||||
"settings_panel_title": "Tytuł panelu bocznego",
|
||||
"settings_notifications": "Powiadomienia",
|
||||
@@ -864,6 +878,9 @@
|
||||
"battery_fleet_setup_done": "Flota baterii skonfigurowana — jedno zadanie śledzi wszystkie.",
|
||||
"update_banner": "Na serwerze jest nowsza wersja Maintenance Supporter — załaduj ponownie, aby zaktualizować panel.",
|
||||
"update_reload": "Załaduj ponownie",
|
||||
"row_actions_banner": "Ukończ i Pomiń w wierszach zadań są teraz przyciskami. Wolisz stare ikony?",
|
||||
"row_actions_keep": "Zachowaj przyciski",
|
||||
"row_actions_back": "Wróć do ikon",
|
||||
"battery_fleet_forecast_overdue": "Przewidywana data minęła — bateria nadal zgłasza dobry stan. Jeśli ją wymieniono, zapisz wymianę; w przeciwnym razie prognoza była błędna.",
|
||||
"cost_from_parts": "Użyj ≈ {amount} z części",
|
||||
"dismiss": "Odrzuć",
|
||||
|
||||
@@ -376,6 +376,7 @@
|
||||
"card_title": "Título",
|
||||
"card_show_header": "Mostrar cabeçalho com estatísticas",
|
||||
"card_show_actions": "Mostrar botões de ação",
|
||||
"card_action_style": "Estilo do botão Concluir",
|
||||
"card_compact": "Modo compacto",
|
||||
"card_max_items": "Máx. de itens (0 = todos)",
|
||||
"card_filter_status": "Filtrar por status",
|
||||
@@ -450,6 +451,19 @@
|
||||
"feat_checklists_desc": "Procedimentos em várias etapas para concluir tarefas",
|
||||
"settings_general": "Geral",
|
||||
"settings_default_warning": "Dias de aviso padrão",
|
||||
"settings_consumable_threshold": "Limite „consumível baixo“ (%)",
|
||||
"settings_battery_low_percent": "Limite „pilha fraca“ (%)",
|
||||
"settings_thresholds_hint": "Padrões para novas Configurações sugeridas (porcentagem restante) e para pilhas da frota sem limite próprio do Battery Notes. As tarefas existentes mantêm seu valor.",
|
||||
"bn_summary": "{name}: {pct} % para {n} pilhas",
|
||||
"bn_floor_decides": "Seu limite de {floor} % decide para todas; por pilha vale o maior dos dois limites.",
|
||||
"bn_above_floor": "Está acima do seu limite de {floor} %, então o {name} decide para todas as pilhas anotadas.",
|
||||
"bn_overrides": "{n} dispositivos com limite próprio:",
|
||||
"bn_more": "+ {n} mais",
|
||||
"settings_row_actions": "Ações nas linhas de tarefas",
|
||||
"row_actions_buttons_compact": "Botões (só ícones no celular)",
|
||||
"row_actions_buttons": "Botões com texto",
|
||||
"row_actions_icons": "Ícones (clássico)",
|
||||
"row_actions_follow": "Seguir a configuração da casa",
|
||||
"settings_panel_enabled": "Painel na barra lateral",
|
||||
"settings_panel_title": "Título do painel na barra lateral",
|
||||
"settings_notifications": "Notificações",
|
||||
@@ -864,6 +878,9 @@
|
||||
"battery_fleet_setup_done": "Frota de baterias configurada — uma única tarefa acompanha todas as suas baterias.",
|
||||
"update_banner": "Há uma versão mais nova do Maintenance Supporter no servidor — recarregue para atualizar o painel.",
|
||||
"update_reload": "Recarregar",
|
||||
"row_actions_banner": "Concluir e Pular agora são botões nas linhas de tarefas. Prefere os ícones antigos?",
|
||||
"row_actions_keep": "Manter botões",
|
||||
"row_actions_back": "Voltar aos ícones",
|
||||
"battery_fleet_forecast_overdue": "Data prevista ultrapassada — a bateria ainda reporta bom estado. Se você a trocou, registre a troca; caso contrário, a previsão errou.",
|
||||
"cost_from_parts": "Usar ≈ {amount} das peças",
|
||||
"dismiss": "Dispensar",
|
||||
|
||||
@@ -374,6 +374,7 @@
|
||||
"card_title": "Título",
|
||||
"card_show_header": "Mostrar cabeçalho com estatísticas",
|
||||
"card_show_actions": "Mostrar botões de ação",
|
||||
"card_action_style": "Estilo do botão Concluir",
|
||||
"card_compact": "Modo compacto",
|
||||
"card_max_items": "Máx. itens (0 = todos)",
|
||||
"card_filter_status": "Filtrar por estado",
|
||||
@@ -448,6 +449,19 @@
|
||||
"feat_checklists_desc": "Procedimentos com vários passos para conclusão de tarefas",
|
||||
"settings_general": "Geral",
|
||||
"settings_default_warning": "Dias de aviso predefinidos",
|
||||
"settings_consumable_threshold": "Limiar „consumível baixo“ (%)",
|
||||
"settings_battery_low_percent": "Limiar „pilha fraca“ (%)",
|
||||
"settings_thresholds_hint": "Predefinições para novas Configurações sugeridas (percentagem restante) e para pilhas da frota sem limiar Battery Notes próprio. As tarefas existentes mantêm o seu valor.",
|
||||
"bn_summary": "{name}: {pct} % para {n} pilhas",
|
||||
"bn_floor_decides": "O teu limiar de {floor} % decide para todas; por pilha conta o mais alto dos dois limiares.",
|
||||
"bn_above_floor": "Está acima do teu limiar de {floor} %, por isso o {name} decide para todas as pilhas anotadas.",
|
||||
"bn_overrides": "{n} dispositivos com limiar próprio:",
|
||||
"bn_more": "+ {n} mais",
|
||||
"settings_row_actions": "Ações nas linhas de tarefas",
|
||||
"row_actions_buttons_compact": "Botões (só ícones no telemóvel)",
|
||||
"row_actions_buttons": "Botões com texto",
|
||||
"row_actions_icons": "Ícones (clássico)",
|
||||
"row_actions_follow": "Seguir a definição da casa",
|
||||
"settings_panel_enabled": "Painel lateral",
|
||||
"settings_panel_title": "Título do painel lateral",
|
||||
"settings_notifications": "Notificações",
|
||||
@@ -864,6 +878,9 @@
|
||||
"battery_fleet_setup_done": "Frota de baterias configurada — uma tarefa acompanha todas.",
|
||||
"update_banner": "Há uma versão mais recente do Maintenance Supporter no servidor — recarregue para atualizar o painel.",
|
||||
"update_reload": "Recarregar",
|
||||
"row_actions_banner": "Concluir e Ignorar são agora botões nas linhas de tarefas. Prefere os ícones antigos?",
|
||||
"row_actions_keep": "Manter botões",
|
||||
"row_actions_back": "Voltar aos ícones",
|
||||
"battery_fleet_forecast_overdue": "Data prevista ultrapassada — a bateria continua a reportar bom estado. Se a substituiu, registe a substituição; caso contrário, a previsão falhou.",
|
||||
"cost_from_parts": "Usar ≈ {amount} das peças",
|
||||
"dismiss": "Dispensar",
|
||||
|
||||
@@ -374,6 +374,7 @@
|
||||
"card_title": "Заголовок",
|
||||
"card_show_header": "Показывать заголовок со статистикой",
|
||||
"card_show_actions": "Показывать кнопки действий",
|
||||
"card_action_style": "Стиль кнопки «Выполнено»",
|
||||
"card_compact": "Компактный режим",
|
||||
"card_max_items": "Макс. элементов (0 = все)",
|
||||
"card_filter_status": "Фильтровать по статусу",
|
||||
@@ -448,6 +449,19 @@
|
||||
"feat_checklists_desc": "Многошаговые процедуры для выполнения задачи",
|
||||
"settings_general": "Основные",
|
||||
"settings_default_warning": "Дни предупреждения по умолчанию",
|
||||
"settings_consumable_threshold": "Порог «расходник заканчивается» (%)",
|
||||
"settings_battery_low_percent": "Порог «батарея разряжена» (%)",
|
||||
"settings_thresholds_hint": "Значения по умолчанию для новых «Предлагаемых настроек» (остаток в процентах) и для батарей парка без собственного порога Battery Notes. Существующие задачи сохраняют своё значение.",
|
||||
"bn_summary": "{name}: {pct} % для {n} батарей",
|
||||
"bn_floor_decides": "Ваш порог {floor} % действует для всех; для каждой батареи учитывается больший из двух порогов.",
|
||||
"bn_above_floor": "Это выше вашего порога {floor} %, поэтому для всех отмеченных батарей решает {name}.",
|
||||
"bn_overrides": "{n} устройств с собственным порогом:",
|
||||
"bn_more": "+ ещё {n}",
|
||||
"settings_row_actions": "Действия в строках задач",
|
||||
"row_actions_buttons_compact": "Кнопки (на телефоне только значки)",
|
||||
"row_actions_buttons": "Кнопки с текстом",
|
||||
"row_actions_icons": "Значки (классический вид)",
|
||||
"row_actions_follow": "Как в настройках дома",
|
||||
"settings_panel_enabled": "Боковая панель",
|
||||
"settings_panel_title": "Заголовок панели",
|
||||
"settings_notifications": "Уведомления",
|
||||
@@ -864,6 +878,9 @@
|
||||
"battery_fleet_setup_done": "Парк батарей настроен — одна задача следит за всеми.",
|
||||
"update_banner": "На сервере более новая версия Maintenance Supporter — перезагрузите, чтобы обновить панель.",
|
||||
"update_reload": "Перезагрузить",
|
||||
"row_actions_banner": "«Выполнено» и «Пропустить» в строках задач теперь кнопки. Предпочитаете старые значки?",
|
||||
"row_actions_keep": "Оставить кнопки",
|
||||
"row_actions_back": "Вернуть значки",
|
||||
"battery_fleet_forecast_overdue": "Прогнозируемая дата прошла — батарея по-прежнему сообщает о хорошем состоянии. Если вы её заменили, зафиксируйте замену; иначе прогноз оказался неверным.",
|
||||
"cost_from_parts": "Взять ≈ {amount} из запчастей",
|
||||
"dismiss": "Скрыть",
|
||||
|
||||
@@ -374,6 +374,7 @@
|
||||
"card_title": "Titel",
|
||||
"card_show_header": "Visa rubrik med statistik",
|
||||
"card_show_actions": "Visa åtgärdsknappar",
|
||||
"card_action_style": "Stil på Slutför-knappen",
|
||||
"card_compact": "Kompakt läge",
|
||||
"card_max_items": "Max objekt (0 = alla)",
|
||||
"card_filter_status": "Filtrera efter status",
|
||||
@@ -448,6 +449,19 @@
|
||||
"feat_checklists_desc": "Flerstegs procedurer för uppgiftens slutförande",
|
||||
"settings_general": "Allmänt",
|
||||
"settings_default_warning": "Standard varningsdagar",
|
||||
"settings_consumable_threshold": "Tröskel „förbrukningsartikel låg“ (%)",
|
||||
"settings_battery_low_percent": "Tröskel „batteri lågt“ (%)",
|
||||
"settings_thresholds_hint": "Standardvärden för nya Föreslagna inställningar (återstående procent) och för flottans batterier utan egen Battery Notes-tröskel. Befintliga uppgifter behåller sitt värde.",
|
||||
"bn_summary": "{name}: {pct} % för {n} batterier",
|
||||
"bn_floor_decides": "Din tröskel på {floor} % avgör för alla; per batteri gäller den högre av de två trösklarna.",
|
||||
"bn_above_floor": "Det är över din tröskel på {floor} %, så {name} avgör för alla noterade batterier.",
|
||||
"bn_overrides": "{n} enheter med egen tröskel:",
|
||||
"bn_more": "+ {n} till",
|
||||
"settings_row_actions": "Åtgärder i uppgiftsrader",
|
||||
"row_actions_buttons_compact": "Knappar (endast ikoner på mobilen)",
|
||||
"row_actions_buttons": "Knappar med text",
|
||||
"row_actions_icons": "Ikoner (klassiskt)",
|
||||
"row_actions_follow": "Följ hushållets inställning",
|
||||
"settings_panel_enabled": "Sidopanel",
|
||||
"settings_panel_title": "Sidopanelens titel",
|
||||
"settings_notifications": "Notifikationer",
|
||||
@@ -864,6 +878,9 @@
|
||||
"battery_fleet_setup_done": "Batteriflotta konfigurerad — en uppgift följer alla batterier.",
|
||||
"update_banner": "En nyare version av Maintenance Supporter finns på servern — ladda om för att uppdatera panelen.",
|
||||
"update_reload": "Ladda om",
|
||||
"row_actions_banner": "Slutför och Hoppa över är nu knappar i uppgiftsraderna. Föredrar du de gamla ikonerna?",
|
||||
"row_actions_keep": "Behåll knappar",
|
||||
"row_actions_back": "Tillbaka till ikoner",
|
||||
"battery_fleet_forecast_overdue": "Förutsagt datum har passerat — batteriet rapporterar fortfarande god status. Om du bytte det, registrera bytet; annars slog prognosen fel.",
|
||||
"cost_from_parts": "Använd ≈ {amount} från delar",
|
||||
"dismiss": "Avfärda",
|
||||
|
||||
@@ -376,6 +376,7 @@
|
||||
"card_title": "Başlık",
|
||||
"card_show_header": "İstatistikli başlığı göster",
|
||||
"card_show_actions": "Eylem düğmelerini göster",
|
||||
"card_action_style": "Tamamla düğmesi stili",
|
||||
"card_compact": "Kompakt mod",
|
||||
"card_max_items": "En fazla öğe (0 = tümü)",
|
||||
"card_filter_status": "Duruma göre filtrele",
|
||||
@@ -450,6 +451,19 @@
|
||||
"feat_checklists_desc": "Görev tamamlama için çok adımlı prosedürler",
|
||||
"settings_general": "Genel",
|
||||
"settings_default_warning": "Varsayılan uyarı günleri",
|
||||
"settings_consumable_threshold": "„Sarf malzemesi düşük“ eşiği (%)",
|
||||
"settings_battery_low_percent": "„Pil zayıf“ eşiği (%)",
|
||||
"settings_thresholds_hint": "Yeni Önerilen kurulumlar (kalan yüzde) ve kendi Battery Notes eşiği olmayan filo pilleri için varsayılanlar. Mevcut görevler değerlerini korur.",
|
||||
"bn_summary": "{name}: {n} pil için % {pct}",
|
||||
"bn_floor_decides": "Senin % {floor} eşiğin hepsi için belirleyici; her pil için iki eşikten yüksek olanı geçerli.",
|
||||
"bn_above_floor": "Bu, senin % {floor} eşiğinin üzerinde; bu yüzden kayıtlı tüm piller için {name} belirleyici.",
|
||||
"bn_overrides": "Kendi eşiği olan {n} cihaz:",
|
||||
"bn_more": "+ {n} tane daha",
|
||||
"settings_row_actions": "Görev satırı eylemleri",
|
||||
"row_actions_buttons_compact": "Düğmeler (telefonda yalnızca simgeler)",
|
||||
"row_actions_buttons": "Metinli düğmeler",
|
||||
"row_actions_icons": "Simgeler (klasik)",
|
||||
"row_actions_follow": "Ev ayarını izle",
|
||||
"settings_panel_enabled": "Kenar çubuğu paneli",
|
||||
"settings_panel_title": "Kenar çubuğu paneli başlığı",
|
||||
"settings_notifications": "Bildirimler",
|
||||
@@ -864,6 +878,9 @@
|
||||
"battery_fleet_setup_done": "Pil filosu kuruldu — tek bir görev tüm pillerinizi takip ediyor.",
|
||||
"update_banner": "Sunucuda Maintenance Supporter'ın daha yeni bir sürümü var — paneli güncellemek için sayfayı yeniden yükleyin.",
|
||||
"update_reload": "Yeniden yükle",
|
||||
"row_actions_banner": "Görev satırlarındaki Tamamla ve Atla artık düğme. Eski simgeleri mi tercih edersiniz?",
|
||||
"row_actions_keep": "Düğmeleri koru",
|
||||
"row_actions_back": "Simgelere geri dön",
|
||||
"battery_fleet_forecast_overdue": "Öngörülen tarih geçti — pil hâlâ sağlıklı görünüyor. Değiştirdiyseniz değişimi kaydedin; aksi halde tahmin yanılmış demektir.",
|
||||
"cost_from_parts": "Parçalardan ≈ {amount} kullan",
|
||||
"dismiss": "Kapat",
|
||||
|
||||
@@ -374,6 +374,7 @@
|
||||
"card_title": "Заголовок",
|
||||
"card_show_header": "Показувати заголовок зі статистикою",
|
||||
"card_show_actions": "Показувати кнопки дій",
|
||||
"card_action_style": "Стиль кнопки «Виконано»",
|
||||
"card_compact": "Компактний режим",
|
||||
"card_max_items": "Макс. елементів (0 = всі)",
|
||||
"card_filter_status": "Фільтрувати за статусом",
|
||||
@@ -448,6 +449,19 @@
|
||||
"feat_checklists_desc": "Багатокрокові процедури для виконання завдань",
|
||||
"settings_general": "Загальне",
|
||||
"settings_default_warning": "Днів попередження за замовчуванням",
|
||||
"settings_consumable_threshold": "Поріг «витратний матеріал закінчується» (%)",
|
||||
"settings_battery_low_percent": "Поріг «батарея розряджена» (%)",
|
||||
"settings_thresholds_hint": "Значення за замовчуванням для нових «Запропонованих налаштувань» (залишок у відсотках) і для батарей парку без власного порогу Battery Notes. Наявні завдання зберігають своє значення.",
|
||||
"bn_summary": "{name}: {pct} % для {n} батарей",
|
||||
"bn_floor_decides": "Ваш поріг {floor} % діє для всіх; для кожної батареї враховується більший із двох порогів.",
|
||||
"bn_above_floor": "Це вище за ваш поріг {floor} %, тому для всіх занотованих батарей вирішує {name}.",
|
||||
"bn_overrides": "{n} пристроїв із власним порогом:",
|
||||
"bn_more": "+ ще {n}",
|
||||
"settings_row_actions": "Дії в рядках завдань",
|
||||
"row_actions_buttons_compact": "Кнопки (на телефоні лише значки)",
|
||||
"row_actions_buttons": "Кнопки з текстом",
|
||||
"row_actions_icons": "Значки (класичний вигляд)",
|
||||
"row_actions_follow": "Як у налаштуваннях дому",
|
||||
"settings_panel_enabled": "Панель у бічному меню",
|
||||
"settings_panel_title": "Заголовок панелі",
|
||||
"settings_notifications": "Сповіщення",
|
||||
@@ -864,6 +878,9 @@
|
||||
"battery_fleet_setup_done": "Парк батарей налаштовано — одне завдання стежить за всіма.",
|
||||
"update_banner": "На сервері новіша версія Maintenance Supporter — перезавантажте, щоб оновити панель.",
|
||||
"update_reload": "Перезавантажити",
|
||||
"row_actions_banner": "«Виконано» та «Пропустити» в рядках завдань тепер кнопки. Бажаєте старі значки?",
|
||||
"row_actions_keep": "Залишити кнопки",
|
||||
"row_actions_back": "Повернути значки",
|
||||
"battery_fleet_forecast_overdue": "Прогнозована дата минула — батарея й далі повідомляє про добрий стан. Якщо ви її замінили, зафіксуйте заміну; інакше прогноз не справдився.",
|
||||
"cost_from_parts": "Узяти ≈ {amount} із запчастин",
|
||||
"dismiss": "Приховати",
|
||||
|
||||
@@ -376,6 +376,7 @@
|
||||
"card_title": "标题",
|
||||
"card_show_header": "显示统计信息页眉",
|
||||
"card_show_actions": "显示操作按钮",
|
||||
"card_action_style": "“完成”按钮样式",
|
||||
"card_compact": "紧凑模式",
|
||||
"card_max_items": "最大显示项 (0 = 全部)",
|
||||
"card_filter_status": "按状态过滤",
|
||||
@@ -450,6 +451,19 @@
|
||||
"feat_checklists_desc": "为任务完成提供多步骤操作流程",
|
||||
"settings_general": "常规",
|
||||
"settings_default_warning": "默认预警天数",
|
||||
"settings_consumable_threshold": "耗材“不足”阈值(%)",
|
||||
"settings_battery_low_percent": "电池“电量低”阈值(%)",
|
||||
"settings_thresholds_hint": "用于新的“建议设置”(剩余百分比)以及没有自身 Battery Notes 阈值的电池组电池。现有任务保留其值。",
|
||||
"bn_summary": "{name}:{n} 块电池为 {pct}%",
|
||||
"bn_floor_decides": "你的 {floor}% 阈值对所有电池生效;每块电池取两个阈值中较高者。",
|
||||
"bn_above_floor": "这高于你的 {floor}% 阈值,因此所有已记录的电池由 {name} 决定。",
|
||||
"bn_overrides": "{n} 台设备有自己的阈值:",
|
||||
"bn_more": "+ 另外 {n} 个",
|
||||
"settings_row_actions": "任务行操作",
|
||||
"row_actions_buttons_compact": "按钮(手机上仅显示图标)",
|
||||
"row_actions_buttons": "带文字的按钮",
|
||||
"row_actions_icons": "图标(经典)",
|
||||
"row_actions_follow": "跟随全局设置",
|
||||
"settings_panel_enabled": "侧边栏面板",
|
||||
"settings_panel_title": "侧边栏面板标题",
|
||||
"settings_notifications": "通知",
|
||||
@@ -864,6 +878,9 @@
|
||||
"battery_fleet_setup_done": "电池群已设置 — 一个任务跟踪所有电池。",
|
||||
"update_banner": "服务器上有更新版本的 Maintenance Supporter——请重新加载以更新面板。",
|
||||
"update_reload": "重新加载",
|
||||
"row_actions_banner": "任务行中的“完成”和“跳过”现在是按钮。更喜欢旧的图标?",
|
||||
"row_actions_keep": "保留按钮",
|
||||
"row_actions_back": "改回图标",
|
||||
"battery_fleet_forecast_overdue": "预测日期已过——电池仍报告状态良好。如果您已更换电池,请记录更换;否则说明预测有误。",
|
||||
"cost_from_parts": "采用配件合计 ≈ {amount}",
|
||||
"dismiss": "忽略",
|
||||
|
||||
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.68.0 */
|
||||
var S="2.68.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{}})();
|
||||
/*! maintenance_supporter frontend 2.70.0 */
|
||||
var S="2.70.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{}})();
|
||||
|
||||
+250
@@ -0,0 +1,250 @@
|
||||
/*! maintenance_supporter frontend 2.70.0 */
|
||||
import{a as f}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-J4OBOTUE.js";import{a as p}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-KKI2ZPWC.js";import{a as o,b as _,c as i,f as l,h,l as g,m as n,q as t,s as u,u as v}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-VSV22GFT.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.70.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.70.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,147 @@
|
||||
/*! maintenance_supporter frontend 2.70.0 */
|
||||
import{a as u}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-KKI2ZPWC.js";import{a as r,b as _,c as l,f as o,h as p,l as d,m as s,q as i,s as h}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-VSV22GFT.js";var a=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(e,n){this._entryId=e,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(e){this._error=u(e,this._lang,i("save_error",this._lang))}finally{this._loading=!1}}}_parentChoices(){return(this.objects||[]).filter(e=>e.entry_id!==this._entryId)}_close(){this._open=!1}render(){if(!this._open)return l``;let e=this._lang,n=this._entryId?i("edit_object",e):i("new_object",e);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",e)}"
|
||||
required
|
||||
.value=${this._name}
|
||||
@input=${t=>this._name=t.target.value}
|
||||
></ms-textfield>
|
||||
<ms-textfield
|
||||
label="${i("manufacturer_optional",e)}"
|
||||
.value=${this._manufacturer}
|
||||
@input=${t=>this._manufacturer=t.target.value}
|
||||
></ms-textfield>
|
||||
<ms-textfield
|
||||
label="${i("model_optional",e)}"
|
||||
.value=${this._model}
|
||||
@input=${t=>this._model=t.target.value}
|
||||
></ms-textfield>
|
||||
<ms-textfield
|
||||
label="${i("serial_number_optional",e)}"
|
||||
.value=${this._serialNumber}
|
||||
@input=${t=>this._serialNumber=t.target.value}
|
||||
></ms-textfield>
|
||||
<ms-textfield
|
||||
label="${i("documentation_url_optional",e)}"
|
||||
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",e)}"
|
||||
.value=${this._areaId}
|
||||
@value-changed=${t=>this._areaId=t.detail.value||""}
|
||||
></ha-area-picker>
|
||||
<ms-textfield
|
||||
label="${i("installation_date_optional",e)}"
|
||||
type="date"
|
||||
.value=${this._installationDate}
|
||||
@input=${t=>this._installationDate=t.target.value}
|
||||
></ms-textfield>
|
||||
<ms-textfield
|
||||
label="${i("warranty_expiry_optional",e)}"
|
||||
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",e)}
|
||||
@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",e)}</span>
|
||||
<select
|
||||
class="parent-select"
|
||||
.value=${this._parentEntryId}
|
||||
@change=${t=>this._parentEntryId=t.target.value}
|
||||
>
|
||||
<option value="" ?selected=${!this._parentEntryId}>
|
||||
${i("parent_none",e)}
|
||||
</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",e)}</span>
|
||||
<textarea
|
||||
rows="3"
|
||||
.value=${this._notes}
|
||||
@input=${t=>this._notes=t.target.value}
|
||||
></textarea>
|
||||
<span class="md-hint">${i("notes_markdown_hint",e)}</span>
|
||||
</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>
|
||||
`}};a.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);
|
||||
}
|
||||
.md-hint {
|
||||
font-size: 11px; color: var(--secondary-text-color); font-style: italic;
|
||||
}
|
||||
.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})],a.prototype,"hass",2),r([d({attribute:!1})],a.prototype,"objects",2),r([s()],a.prototype,"_open",2),r([s()],a.prototype,"_loading",2),r([s()],a.prototype,"_error",2),r([s()],a.prototype,"_name",2),r([s()],a.prototype,"_manufacturer",2),r([s()],a.prototype,"_model",2),r([s()],a.prototype,"_serialNumber",2),r([s()],a.prototype,"_areaId",2),r([s()],a.prototype,"_installationDate",2),r([s()],a.prototype,"_warrantyExpiry",2),r([s()],a.prototype,"_documentationUrl",2),r([s()],a.prototype,"_notes",2),r([s()],a.prototype,"_haDeviceId",2),r([s()],a.prototype,"_parentEntryId",2),r([s()],a.prototype,"_entryId",2);customElements.get("maintenance-object-dialog")||customElements.define("maintenance-object-dialog",a);export{a};
|
||||
@@ -0,0 +1,2 @@
|
||||
/*! maintenance_supporter frontend 2.70.0 */
|
||||
import{q as a}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-VSV22GFT.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.70.0 */
|
||||
import{a as x}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-3L2PFMVS.js";import{a,b as v,c as n,f as p,h as b,l as h,m as c,q as t}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-VSV22GFT.js";function d(l){return l.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""")}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};
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,315 @@
|
||||
/*! maintenance_supporter frontend 2.70.0 */
|
||||
import{a as _,e as k}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-VFPFVFKO.js";import{a as m}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-KKI2ZPWC.js";import{C as g,a as s,b,c as o,f as d,h as v,l as n,m as l,q as r}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-VSV22GFT.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.phaseLabel="";this.requireTagScan=!1;this.viaTagScan=!1;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(e={}){this._open||(this._open=!0,this.viaTagScan=!!e.viaTagScan,this._notes="",this._cost="",this._duration="",this._error="",this._checklistState=Object.fromEntries(this.checklist.map((t,a)=>[String(a),!!this.checklistPrefill[t]]).filter(([,t])=>t)),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(t=>[_(t),{...t}])))}_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,a=t.files?.[0];if(t.value="",!!a){this._photoUploading=!0,this._error="";try{let c=new FormData;c.append("entry_id",this.entryId),c.append("tags","photo"),c.append("file",a,a.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(a))}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.viaTagScan&&(e.via_tag_scan=!0),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=m(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)?o`<span class="req-mark" aria-hidden="true">*</span>`:d}_partsCostSuggestion(){if(this.restockDefault!==null){let a=parseFloat(this._restockQty);return this.restockUnitCost==null||!Number.isFinite(a)||a<=0?null:Math.round(this.restockUnitCost*a*100)/100}if(!this.parts.length)return null;let e=0,t=!1;for(let a of Object.values(this._usedParts)){let c=this.parts.find(p=>_({part_id:p.id,entry_id:p.entry_id})===_(a));c?.cost!=null&&(e+=c.cost*(a.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 a=`${t.toFixed(2)}${this.currencySymbol?` ${this.currencySymbol}`:""}`;return o`<button
|
||||
type="button"
|
||||
class="cost-suggestion"
|
||||
@click=${()=>this._cost=t.toFixed(2)}
|
||||
>${r("cost_from_parts",e).replace("{amount}",a)}</button>`}_close(){this._open=!1}render(){if(!this._open)return o``;let e=this.lang||this.hass?.language||"en";return o`
|
||||
<ha-dialog open @closed=${this._close}>
|
||||
<div class="dialog-title">${r("complete_title",e)}${this.taskName}</div>
|
||||
${this.phaseLabel?o`<div class="phase-line">${r("phase_current",e)}: ${this.phaseLabel}</div>`:d}
|
||||
${this.requireTagScan&&!this.viaTagScan?o`<div class="scan-required-note">${r("require_tag_scan_hint",e)}</div>`:d}
|
||||
<div class="content">
|
||||
${this._error?o`<div class="error">${this._error}</div>`:d}
|
||||
${this.checklist.length>0?o`
|
||||
<div class="checklist-section">
|
||||
<label class="checklist-label">${r("checklist",e)}</label>
|
||||
${this.checklist.map((t,a)=>o`
|
||||
<label class="checklist-item" @click=${()=>this._toggleCheck(a)}>
|
||||
<input type="checkbox" .checked=${!!this._checklistState[String(a)]} />
|
||||
<span>${t}</span>
|
||||
</label>
|
||||
`)}
|
||||
</div>
|
||||
`:d}
|
||||
${this.taskType==="reading"?o`
|
||||
<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?o`<div class="used-parts">
|
||||
<span class="field-label">${r("complete_parts_used",e)}</span>
|
||||
${this.parts.map(t=>{let a=_({part_id:t.id,entry_id:t.entry_id}),c=this._usedParts[a],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 o`<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[a]=h[a]||u:delete h[a],this._usedParts=h}} />
|
||||
<span
|
||||
>${t.name}${t.owner_name?o`<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?o`<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,[a]:{...u,quantity:Number.isFinite(h)&&h>=.01?h:1}}}} />`:d}
|
||||
</div>`})}
|
||||
</div>`:this.consumesInfo.length?o`<div class="consumes-hint">
|
||||
${this.consumesInfo.map(t=>o`<div>${t}</div>`)}
|
||||
</div>`:d}
|
||||
${this.restockDefault!==null?o`
|
||||
<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?o`
|
||||
<div class="photo-preview">
|
||||
<img src=${this._photoPreview} alt="" />
|
||||
<button type="button" class="photo-remove" @click=${this._removePhoto}
|
||||
title="${r("remove",e)}">✕</button>
|
||||
</div>`:o`
|
||||
<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?o`
|
||||
<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=[g,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;
|
||||
}
|
||||
.scan-required-note {
|
||||
margin: -4px 0 12px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 152, 0, 0.12);
|
||||
color: var(--primary-text-color);
|
||||
font-size: 13px;
|
||||
}
|
||||
.phase-line {
|
||||
margin-top: -8px;
|
||||
padding-bottom: 12px;
|
||||
font-size: 13px;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.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([n()],i.prototype,"phaseLabel",2),s([n({type:Boolean})],i.prototype,"requireTagScan",2),s([n({type:Boolean})],i.prototype,"viaTagScan",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};
|
||||
@@ -0,0 +1,79 @@
|
||||
/*! maintenance_supporter frontend 2.70.0 */
|
||||
import{a as r,b as l,c as i,f as o,h as n,l as t}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-VSV22GFT.js";var e=class extends n{constructor(){super(...arguments);this.label="";this.value="";this.placeholder="";this.type="text";this.required=!1;this.disabled=!1;this.multiline=!1;this.rows=3}_onInput(s){let a=s.target.value;this.value=a,this.dispatchEvent(new CustomEvent("input",{bubbles:!0,composed:!0,detail:{value:a}}))}render(){return i`
|
||||
<label class="field">
|
||||
${this.label?i`<span class="label">${this.label}${this.required?i`<span class="req">*</span>`:o}</span>`:o}
|
||||
${this.multiline?i`
|
||||
<textarea
|
||||
.value=${this.value??""}
|
||||
rows=${this.rows}
|
||||
?required=${this.required}
|
||||
?disabled=${this.disabled}
|
||||
placeholder=${this.placeholder}
|
||||
@input=${this._onInput}
|
||||
@change=${this._onInput}
|
||||
></textarea>`:i`
|
||||
<input
|
||||
.value=${this.value??""}
|
||||
.type=${this.type}
|
||||
?required=${this.required}
|
||||
?disabled=${this.disabled}
|
||||
placeholder=${this.placeholder}
|
||||
step=${this.step??o}
|
||||
min=${this.min??o}
|
||||
max=${this.max??o}
|
||||
pattern=${this.pattern??o}
|
||||
@input=${this._onInput}
|
||||
@change=${this._onInput}
|
||||
/>`}
|
||||
${this.helper?i`<span class="helper">${this.helper}</span>`:o}
|
||||
</label>
|
||||
`}};e.styles=l`
|
||||
: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; }
|
||||
textarea {
|
||||
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;
|
||||
resize: vertical;
|
||||
}
|
||||
textarea:focus { border-color: var(--primary-color); }
|
||||
textarea:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.helper {
|
||||
font-size: 11px;
|
||||
color: var(--secondary-text-color);
|
||||
font-style: italic;
|
||||
}
|
||||
`,r([t()],e.prototype,"label",2),r([t()],e.prototype,"value",2),r([t()],e.prototype,"placeholder",2),r([t()],e.prototype,"type",2),r([t({type:Boolean})],e.prototype,"required",2),r([t({type:Boolean})],e.prototype,"disabled",2),r([t()],e.prototype,"step",2),r([t()],e.prototype,"min",2),r([t()],e.prototype,"max",2),r([t()],e.prototype,"pattern",2),r([t()],e.prototype,"helper",2),r([t({type:Boolean})],e.prototype,"multiline",2),r([t({type:Number})],e.prototype,"rows",2);customElements.get("ms-textfield")||customElements.define("ms-textfield",e);
|
||||
@@ -0,0 +1,2 @@
|
||||
/*! maintenance_supporter frontend 2.70.0 */
|
||||
import{b as i}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-3L2PFMVS.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,2 @@
|
||||
/*! maintenance_supporter frontend 2.70.0 */
|
||||
import{q as _}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-VSV22GFT.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};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
@@ -0,0 +1,2 @@
|
||||
/*! maintenance_supporter frontend 2.70.0 */
|
||||
import{a}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-SBOLPJAX.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-VFPFVFKO.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-KKI2ZPWC.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-VSV22GFT.js";export{a as MaintenanceCompleteDialog};
|
||||
@@ -0,0 +1,2 @@
|
||||
/*! maintenance_supporter frontend 2.70.0 */
|
||||
import{A as k,q as a,r as b,s as c,t as d,u as e,v as f,w as g,x as h,y as i,z as j}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-S2O7XNSC.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-JSY4RS7D.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-VZ2E6UPO.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-SYT5D73A.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-SBOLPJAX.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-VFPFVFKO.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-MKNEWK2D.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-3L2PFMVS.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-J4OBOTUE.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-KKI2ZPWC.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-VSV22GFT.js";export{b as __resetSettingsCacheForTests,a as getRowActionStyle,h as openCompleteDialog,c as openCreateObjectDialog,e as openCreateTaskDialog,d as openEditObjectDialog,f as openEditTaskDialog,g as openHistoryEditDialog,k as openObjectQuickActions,i as openQrDialog,j as openTaskQuickActions};
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
/*! maintenance_supporter frontend 2.70.0 */
|
||||
import{a}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-JSY4RS7D.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-SYT5D73A.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-KKI2ZPWC.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-VSV22GFT.js";export{a as MaintenanceObjectDialog};
|
||||
@@ -0,0 +1,2 @@
|
||||
/*! maintenance_supporter frontend 2.70.0 */
|
||||
import{a}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-MKNEWK2D.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-3L2PFMVS.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-VSV22GFT.js";export{a as MaintenanceQrDialog};
|
||||
+1206
File diff suppressed because one or more lines are too long
+146
@@ -0,0 +1,146 @@
|
||||
/*! maintenance_supporter frontend 2.70.0 */
|
||||
import{a as g}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-KKI2ZPWC.js";import{a as l,b as m,c as i,f as h,h as v,l as u,m as d,q as p,s as f,u as x}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-VSV22GFT.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.70.0 */
|
||||
import{a}from"/maintenance_supporter_panelfiles/panel-chunks/chunk-VZ2E6UPO.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-SYT5D73A.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-VFPFVFKO.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-J4OBOTUE.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-KKI2ZPWC.js";import"/maintenance_supporter_panelfiles/panel-chunks/chunk-VSV22GFT.js";export{a as MaintenanceTaskDialog};
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
/*! maintenance_supporter frontend 2.70.0 */
|
||||
import{a as m,b as f}from"./chunk-ER5MID6O.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-JMXXW5KM.js";import{a as n}from"./chunk-CAQJ66HF.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.70.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};
|
||||
@@ -0,0 +1,60 @@
|
||||
/*! maintenance_supporter frontend 2.70.0 */
|
||||
import{a as t}from"./chunk-JMXXW5KM.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
+2876
File diff suppressed because one or more lines are too long
+137
@@ -0,0 +1,137 @@
|
||||
/*! maintenance_supporter frontend 2.70.0 */
|
||||
import{a as v,b}from"./chunk-ER5MID6O.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-JMXXW5KM.js";import{a}from"./chunk-CAQJ66HF.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};
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
/*! maintenance_supporter frontend 2.70.0 */
|
||||
import{a as m,b as g}from"./chunk-ER5MID6O.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-JMXXW5KM.js";import{a as i}from"./chunk-CAQJ66HF.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};
|
||||
+3
-3
File diff suppressed because one or more lines are too long
@@ -445,6 +445,10 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
|
||||
self-charging filter is off entirely and such devices surface as
|
||||
rechargeables (native rows typed "Rechargeable", never "Unknown").
|
||||
"""
|
||||
# #146: the fleet-wide low floor is a household setting (default 20 %).
|
||||
from .global_options import get_battery_low_percent
|
||||
|
||||
floor = float(get_battery_low_percent(hass))
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
@@ -525,7 +529,7 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
|
||||
# was "healthy" here while the same level counted low in the
|
||||
# native pass. A HIGHER Battery Notes threshold (e.g. 30 %)
|
||||
# still wins through battery_low.
|
||||
low = bool(attrs.get("battery_low")) or (level is not None and level <= NATIVE_LOW_PERCENT)
|
||||
low = bool(attrs.get("battery_low")) or (level is not None and level <= floor)
|
||||
last_replaced = _parse_last_replaced(attrs.get("battery_last_replaced"))
|
||||
# B1 (roadmap 2026-07-22 audit): a forecast-only note — no level
|
||||
# sensor, so the state reads unknown forever — must SURVIVE when it
|
||||
@@ -575,7 +579,7 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
|
||||
last_replaced=last_replaced,
|
||||
available=available,
|
||||
source="battery_notes",
|
||||
low_threshold=_note_low_threshold(attrs),
|
||||
low_threshold=_note_low_threshold(attrs, floor),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -648,7 +652,7 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
|
||||
if low_state is not None:
|
||||
low = low_available and str(low_state).lower() in ("on", "true", "1")
|
||||
else:
|
||||
low = level is not None and level <= NATIVE_LOW_PERCENT
|
||||
low = level is not None and level <= floor
|
||||
if available:
|
||||
# Remember the last real reading — the retention path below needs
|
||||
# it once the entity goes unavailable.
|
||||
@@ -683,6 +687,61 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
|
||||
return out
|
||||
|
||||
|
||||
def battery_notes_summary(hass: HomeAssistant) -> dict[str, Any] | None:
|
||||
"""What Battery Notes currently reports, for the Settings hint (#146).
|
||||
|
||||
Derived purely from the ``battery_low_threshold`` attribute the
|
||||
``battery_plus`` entities expose — no reach into Battery Notes' own
|
||||
configuration. The most common value is their default; devices whose
|
||||
value differs are overrides, named (highest first, capped at 5 so a
|
||||
large install cannot spam the settings page) and linked via their
|
||||
device id when the registry knows one. ``None`` when Battery Notes is
|
||||
not present.
|
||||
"""
|
||||
from collections import Counter
|
||||
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
ent_reg = er.async_get(hass)
|
||||
dev_reg = dr.async_get(hass)
|
||||
per_device: dict[str, dict[str, Any]] = {}
|
||||
for domain in ("sensor", "binary_sensor"):
|
||||
for state in hass.states.async_all(domain):
|
||||
raw = state.attributes.get("battery_low_threshold")
|
||||
if not isinstance(raw, (int, float)) or isinstance(raw, bool):
|
||||
continue
|
||||
reg = ent_reg.async_get(state.entity_id)
|
||||
device_id = reg.device_id if reg else None
|
||||
name = state.attributes.get("device_name")
|
||||
if device_id and (device := dev_reg.async_get(device_id)):
|
||||
name = device.name_by_user or device.name or name
|
||||
key = device_id or state.entity_id
|
||||
row = per_device.get(key)
|
||||
threshold = float(raw)
|
||||
if row is None or threshold > row["threshold"]:
|
||||
per_device[key] = {
|
||||
"name": str(name or state.entity_id),
|
||||
"device_id": device_id,
|
||||
"threshold": threshold,
|
||||
}
|
||||
if not per_device:
|
||||
return None
|
||||
counts = Counter(row["threshold"] for row in per_device.values())
|
||||
default = counts.most_common(1)[0][0]
|
||||
overrides = sorted(
|
||||
(row for row in per_device.values() if row["threshold"] != default),
|
||||
key=lambda r: (-r["threshold"], r["name"].lower()),
|
||||
)
|
||||
return {
|
||||
"default": default,
|
||||
"devices": len(per_device),
|
||||
"overrides": overrides[:5],
|
||||
"more": max(0, len(overrides) - 5),
|
||||
}
|
||||
|
||||
|
||||
|
||||
def has_battery_notes(hass: HomeAssistant) -> bool:
|
||||
"""Whether the Battery Notes integration is present (any battery_plus).
|
||||
|
||||
@@ -851,12 +910,16 @@ def _detect_unrecorded_jump(
|
||||
return None
|
||||
|
||||
|
||||
def _note_low_threshold(attrs: dict[str, Any]) -> float:
|
||||
"""The Battery-Notes-configured threshold OR the fleet floor — the higher."""
|
||||
def _note_low_threshold(attrs: dict[str, Any], floor: float = float(NATIVE_LOW_PERCENT)) -> float:
|
||||
"""The Battery-Notes-configured threshold OR the fleet floor — the higher.
|
||||
|
||||
``floor`` is the household's battery_low_percent (#146); the module
|
||||
constant only remains as the fallback default.
|
||||
"""
|
||||
raw = attrs.get("battery_low_threshold")
|
||||
if isinstance(raw, (int, float)):
|
||||
return float(max(raw, NATIVE_LOW_PERCENT))
|
||||
return float(NATIVE_LOW_PERCENT)
|
||||
return float(max(raw, floor))
|
||||
return float(floor)
|
||||
|
||||
|
||||
async def async_level_history(hass: HomeAssistant, batteries: list[Battery]) -> dict[str, dict[str, Any]]:
|
||||
@@ -941,6 +1004,7 @@ __all__ = [
|
||||
"async_compute_overview",
|
||||
"async_level_history",
|
||||
"async_trend_predictions",
|
||||
"battery_notes_summary",
|
||||
"build_overview",
|
||||
"compute_overview",
|
||||
"discover_battery_types",
|
||||
|
||||
@@ -492,6 +492,53 @@ async def _reconcile_fleet_task(hass: HomeAssistant, entry: ConfigEntry, lang: s
|
||||
return True
|
||||
|
||||
|
||||
async def reconcile_fleet_parts_at_start(hass: HomeAssistant, entry: ConfigEntry, lang: str) -> dict[str, Any]:
|
||||
"""Boot-time reconcile of the fleet's type-parts (issue #148).
|
||||
|
||||
A battery whose TYPE only becomes known after the fleet was set up (a
|
||||
Battery Notes note added later, or a low-only Matter lock whose typed
|
||||
``…_battery_plus_low`` binary arrived with #121) never got its spare
|
||||
part: ``_reconcile_type_parts`` only ran on an explicit fleet-setup
|
||||
call. Runs the same reconcile once per start — and prunes the legacy
|
||||
"UNKNOWN battery" part older versions minted for untyped batteries,
|
||||
but ONLY while it is completely untouched (no stock counted, no
|
||||
product/vendor data, auto-buy off); the moment a user touched it, it
|
||||
is theirs and stays.
|
||||
"""
|
||||
types = discover_battery_types(hass)
|
||||
added = _reconcile_type_parts(hass, entry, types, lang)
|
||||
|
||||
pruned = False
|
||||
rd = getattr(entry, "runtime_data", None)
|
||||
store = getattr(rd, "store", None) if rd else None
|
||||
|
||||
parts = dict(entry.data.get(CONF_PARTS) or {})
|
||||
legacy = parts.get("batt_unknown")
|
||||
if legacy is not None:
|
||||
stock = store.get_part_stock("batt_unknown") if store is not None else None
|
||||
untouched = (
|
||||
not stock
|
||||
and not legacy.get("product_url")
|
||||
and not legacy.get("vendor")
|
||||
and not legacy.get("mpn")
|
||||
and not legacy.get("gtin")
|
||||
and not legacy.get("auto_buy_task")
|
||||
)
|
||||
if untouched:
|
||||
parts.pop("batt_unknown")
|
||||
new_data = dict(entry.data)
|
||||
new_data[CONF_PARTS] = parts
|
||||
hass.config_entries.async_update_entry(entry, data=new_data)
|
||||
pruned = True
|
||||
|
||||
if store is not None and (added or pruned):
|
||||
for pid in added:
|
||||
store.set_part_stock(pid, 0)
|
||||
await store.async_save()
|
||||
return {"added": added, "pruned": pruned}
|
||||
|
||||
|
||||
|
||||
def _reconcile_type_parts(hass: HomeAssistant, entry: ConfigEntry, types: dict[str, int], lang: str) -> list[str]:
|
||||
"""Add parts for battery types newly seen since setup. Returns added ids
|
||||
(the caller initializes their stock, mirroring the create path)."""
|
||||
|
||||
@@ -19,13 +19,18 @@ from homeassistant.core import HomeAssistant
|
||||
|
||||
from ..const import (
|
||||
CONF_ADVANCED_SCHEDULE_TIME,
|
||||
CONF_BATTERY_LOW_PERCENT,
|
||||
CONF_DEFAULT_CONSUMABLE_THRESHOLD,
|
||||
CONF_DEFAULT_WARNING_DAYS,
|
||||
CONF_PANEL_TITLE,
|
||||
DEFAULT_BATTERY_LOW_PERCENT,
|
||||
DEFAULT_CONSUMABLE_THRESHOLD,
|
||||
DEFAULT_WARNING_DAYS,
|
||||
DOMAIN,
|
||||
GLOBAL_UNIQUE_ID,
|
||||
MAX_PANEL_TITLE_LENGTH,
|
||||
PANEL_TITLE,
|
||||
THRESHOLD_PERCENT_RANGE,
|
||||
)
|
||||
|
||||
|
||||
@@ -89,3 +94,27 @@ def get_panel_title(hass: HomeAssistant) -> str:
|
||||
if not title:
|
||||
return PANEL_TITLE
|
||||
return title[:MAX_PANEL_TITLE_LENGTH]
|
||||
|
||||
|
||||
def _percent_option(hass: HomeAssistant, key: str, default: int) -> int:
|
||||
"""A percent option within THRESHOLD_PERCENT_RANGE, else its default."""
|
||||
raw = get_global_options(hass).get(key, default)
|
||||
try:
|
||||
value = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
lo, hi = THRESHOLD_PERCENT_RANGE
|
||||
return value if lo <= value <= hi else default
|
||||
|
||||
|
||||
def get_consumable_threshold(hass: HomeAssistant) -> int:
|
||||
"""#146: the household's floor for percent-remaining consumables — what
|
||||
Suggested setups pre-wires for catalog signatures that use the default
|
||||
(a signature with its own explicit floor keeps it)."""
|
||||
return _percent_option(hass, CONF_DEFAULT_CONSUMABLE_THRESHOLD, DEFAULT_CONSUMABLE_THRESHOLD)
|
||||
|
||||
|
||||
def get_battery_low_percent(hass: HomeAssistant) -> int:
|
||||
"""#146: the fleet-wide "battery low" floor for batteries without their
|
||||
own Battery Notes threshold (a higher Battery Notes threshold still wins)."""
|
||||
return _percent_option(hass, CONF_BATTERY_LOW_PERCENT, DEFAULT_BATTERY_LOW_PERCENT)
|
||||
|
||||
@@ -34,11 +34,13 @@ from ..const import (
|
||||
CONF_ADVANCED_SCHEDULE_TIME,
|
||||
CONF_ADVANCED_SEASONAL,
|
||||
CONF_ARCHIVE_ONEOFF_DAYS,
|
||||
CONF_BATTERY_LOW_PERCENT,
|
||||
CONF_BUDGET_ALERT_THRESHOLD,
|
||||
CONF_BUDGET_ALERTS_ENABLED,
|
||||
CONF_BUDGET_CURRENCY,
|
||||
CONF_BUDGET_MONTHLY,
|
||||
CONF_BUDGET_YEARLY,
|
||||
CONF_DEFAULT_CONSUMABLE_THRESHOLD,
|
||||
CONF_DEFAULT_WARNING_DAYS,
|
||||
CONF_DELETE_ARCHIVED_ONEOFF_DAYS,
|
||||
CONF_DISABLED_TEMPLATE_IDS,
|
||||
@@ -64,6 +66,8 @@ from ..const import (
|
||||
CONF_QUIET_HOURS_END,
|
||||
CONF_QUIET_HOURS_START,
|
||||
CONF_REMINDER_LEAD_DAYS,
|
||||
CONF_ROW_ACTION_NOTICE,
|
||||
CONF_ROW_ACTION_STYLE,
|
||||
CONF_SHOPPING_LIST_ENTITY,
|
||||
CONF_SNOOZE_DURATION_HOURS,
|
||||
CONF_WARRANTY_REMINDER_DAYS,
|
||||
@@ -90,12 +94,19 @@ SETTING_SPECS: tuple[SettingSpec, ...] = (
|
||||
# 0 = no warning window: new tasks turn due_soon on the due date itself
|
||||
# (the per-task range already allowed 0 — #145).
|
||||
SettingSpec(CONF_DEFAULT_WARNING_DAYS, int, int_range=(0, 365)),
|
||||
# #146: household "low" floors (percent) for discovery and the battery fleet.
|
||||
SettingSpec(CONF_DEFAULT_CONSUMABLE_THRESHOLD, int, int_range=(1, 90)),
|
||||
SettingSpec(CONF_BATTERY_LOW_PERCENT, int, int_range=(1, 90)),
|
||||
SettingSpec(CONF_NOTIFICATIONS_ENABLED, bool),
|
||||
SettingSpec(CONF_NOTIFY_SERVICE, str, max_len=200),
|
||||
# todo.* entity the buy-task shopping sync mirrors into ("" = off);
|
||||
# format-validated by a bespoke sanitiser rule (todo. prefix), max_len is
|
||||
# HA's entity-id cap.
|
||||
SettingSpec(CONF_SHOPPING_LIST_ENTITY, str, max_len=255),
|
||||
# #145: enum-validated by a bespoke rule (ROW_ACTION_STYLES); the notice
|
||||
# flag is only ever cleared by the panel banner.
|
||||
SettingSpec(CONF_ROW_ACTION_STYLE, str, max_len=32),
|
||||
SettingSpec(CONF_ROW_ACTION_NOTICE, bool),
|
||||
SettingSpec(CONF_PANEL_ENABLED, bool),
|
||||
# panel_title is trimmed+capped to MAX_PANEL_TITLE_LENGTH by a bespoke rule,
|
||||
# not a plain drop-if-too-long — so no max_len here.
|
||||
|
||||
@@ -245,6 +245,12 @@ def _threshold_for(sig: ConsumableSignature, hass: HomeAssistant, entity_id: str
|
||||
# entity's own unit — no conversion in either case.
|
||||
return float(sig.delta_units)
|
||||
if sig.direction == "percent_left":
|
||||
# #146: catalog-default floors follow the household setting; a
|
||||
# signature that pins its own floor keeps it.
|
||||
if sig.below_percent == _DEFAULT_BELOW_PERCENT:
|
||||
from ..global_options import get_consumable_threshold
|
||||
|
||||
return float(get_consumable_threshold(hass))
|
||||
return float(sig.below_percent)
|
||||
state = hass.states.get(entity_id)
|
||||
unit = (state.attributes.get("unit_of_measurement") if state else None) or "h"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user