/** Settings view component for the Maintenance Supporter panel. */ import { LitElement, html, css, nothing } from "lit"; import { property, state } from "lit/decorators.js"; import { unsafeHTML } from "lit/directives/unsafe-html.js"; import type { HomeAssistant, AdvancedFeatures, BudgetStatus, HAUser } from "../types"; import { t } from "../styles"; import { UserService } from "../user-service"; import { OBJECT_COLUMNS, sanitizeColumns } from "../helpers/object-columns"; import { downloadTextFile } from "../helpers/download"; /* Settings response shape from WS maintenance_supporter/settings */ interface SettingsResponse { features: AdvancedFeatures; admin_panel_user_ids?: string[]; operator_write_enabled?: boolean; objects_table_columns?: string[]; /** v2.21: template-gallery curation — hidden template ids. */ disabled_template_ids?: string[]; general: { default_warning_days: number; notifications_enabled: boolean; notify_service: string; // Shared pickable-target list computed server-side (build_notify_targets), // so the picker matches the options-flow dropdown exactly. Optional for // backward-compat with an older backend that didn't send it. notify_targets?: string[]; panel_enabled: boolean; panel_title: string; }; notifications: { due_soon_enabled: boolean; due_soon_interval_hours: number; overdue_enabled: boolean; overdue_interval_hours: number; triggered_enabled: boolean; triggered_interval_hours: number; quiet_hours_enabled: boolean; quiet_hours_start: string; quiet_hours_end: string; max_per_day: number; bundling_enabled: boolean; bundle_threshold: number; reminder_lead_days: number[]; scope_view_id: string; }; actions: { complete_enabled: boolean; skip_enabled: boolean; snooze_enabled: boolean; snooze_duration_hours: number; weekly_digest_enabled: boolean; warranty_reminder_enabled: boolean; warranty_reminder_days: number; }; budget: { monthly: number; yearly: number; alerts_enabled: boolean; alert_threshold_pct: number; currency: string; currency_symbol: string; }; // v2.10.0 archive automation (panel-managed). Optional for forward-compat // with a backend that predates the feature. archive?: { oneoff_days: number; delete_archived_oneoff_days: number; }; vacation: { enabled: boolean; start: string | null; end: string | null; buffer_days: number; exempt_task_ids: string[]; is_active: boolean; window_end: string | null; }; } interface VacationPreviewRow { task_id: string; entry_id: string; object_name: string; task_name: string; kind: "time_based" | "sensor_based"; confidence: "deterministic" | "estimated" | "unpredictable"; events: Array<{ date: string; status: "due_soon" | "overdue" | "triggered_est" }>; will_suppress: boolean; } // Keep in lockstep with const.BUDGET_CURRENCIES (keys + order). Parity is // enforced by tests/test_frontend_const_parity.py (drift fails CI). const CURRENCIES = [ "EUR", "USD", "GBP", "JPY", "CHF", "CAD", "AUD", "NZD", "CNY", "INR", "BRL", "CZK", "PLN", "RUB", "SEK", "NOK", "DKK", "UAH", ]; export class MaintenanceSettingsView extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public features!: AdvancedFeatures; @property({ attribute: false }) public budget: BudgetStatus | null = null; @state() private _settings: SettingsResponse | null = null; @state() private _loading = true; @state() private _importCsv = ""; @state() private _importLoading = false; @state() private _includeHistory = true; @state() private _toast = ""; @state() private _testingNotification = false; @state() private _users: HAUser[] = []; @state() private _savedViews: Array<{ id: string; name: string }> = []; // Vacation mode section state (v1.2.0) @state() private _vacEnabled = false; @state() private _vacStart = ""; @state() private _vacEnd = ""; @state() private _vacBuffer = 3; @state() private _vacExempt = new Set(); @state() private _vacIsActive = false; @state() private _vacWindowEnd: string | null = null; @state() private _vacAllTasks: Array<{ entry_id: string; object_name: string; task_id: string; task_name: string }> = []; @state() private _vacPreview: VacationPreviewRow[] = []; @state() private _vacPreviewLoading = false; @state() private _vacSaving = false; // Print QR codes section state @state() private _qrObjects: Array<{ entry_id: string; name: string; task_count: number }> = []; @state() private _qrSelectedEntries = new Set(); @state() private _qrActions = new Set(["view"]); @state() private _qrUrlMode: "server" | "local" | "companion" = "companion"; @state() private _qrBatchLoading = false; @state() private _qrBatchResults: Array<{ entry_id: string; task_id: string; object_name: string; task_name: string; action: string; svg: string; }> = []; @state() private _qrObjectsLoaded = false; // Export/Import selective-object picker state @state() private _exportObjects: Array<{ entry_id: string; name: string; task_count: number }> = []; @state() private _exportSelectedEntries = new Set(); @state() private _exportObjectsLoaded = false; @state() private _docArchiveLoading = false; private _loaded = false; private _userService: UserService | null = null; private get _lang(): string { return this.hass?.language || "en"; } updated(changedProps: Map): void { super.updated(changedProps); if (changedProps.has("hass") && this.hass && !this._loaded) { this._loaded = true; this._userService = new UserService(this.hass); this._loadSettings(); this._loadUsers(); } else if (changedProps.has("hass") && this.hass && this._userService) { this._userService.updateHass(this.hass); } } private async _loadUsers(): Promise { if (!this._userService) return; try { this._users = await this._userService.getUsers(); } catch { this._users = []; } } private async _loadSettings(): Promise { this._loading = true; try { const result = await this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/settings", }); this._settings = result as SettingsResponse; this._hydrateVacationFromSettings(); } catch { /* ignore */ } // Saved views feed the notification-scope picker (best-effort). try { const v = await this.hass.connection.sendMessagePromise<{ views: Array<{ id: string; name: string }> }>({ type: "maintenance_supporter/views/list", }); this._savedViews = v.views || []; } catch { /* ignore */ } this._loading = false; } private _hydrateVacationFromSettings(): void { const v = this._settings?.vacation; if (!v) return; this._vacEnabled = v.enabled; this._vacStart = v.start || ""; this._vacEnd = v.end || ""; this._vacBuffer = v.buffer_days; this._vacExempt = new Set(v.exempt_task_ids || []); this._vacIsActive = v.is_active; this._vacWindowEnd = v.window_end; } private async _updateSetting(key: string, value: unknown): Promise { try { const result = await this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/global/update", settings: { [key]: value }, }); this._settings = result as SettingsResponse; this._showToast(t("settings_saved", this._lang)); this.dispatchEvent(new CustomEvent("settings-changed")); } catch { this._showToast(t("action_error", this._lang)); } } private _sendTestNotification = async (): Promise => { this._testingNotification = true; try { const res = await this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/global/test_notification", }) as { success: boolean; message?: string }; const msg = res.message || (res.success ? t("test_notification_success", this._lang) : t("test_notification_failed", this._lang)); this._showToast(msg); } catch { this._showToast(t("test_notification_failed", this._lang)); } finally { this._testingNotification = false; } }; private _showToast(msg: string): void { this._toast = msg; setTimeout(() => { this._toast = ""; }, 3000); } private _downloadFile(content: string, filename: string, mime: string): void { // Companion-app safe download (target=_blank + DOM + deferred revoke). downloadTextFile(content, filename, mime); } // --- Render --- render() { const L = this._lang; if (this._loading || !this._settings) { return html`
Loading…
`; } return html` ${this._renderFeatures(L)} ${this._renderPanelAccess(L)} ${this._renderGeneral(L)} ${this._renderObjectsColumns(L)} ${this._settings.general.notifications_enabled ? this._renderNotifications(L) : nothing} ${this.features.budget ? this._renderBudget(L) : nothing} ${this._renderArchive(L)} ${this._renderVacation(L)} ${this._renderPrintQr(L)} ${this._renderImportExport(L)} ${this._renderTemplateToggles(L)} ${this._toast ? html`
${this._toast}
` : nothing} `; } /** v2.3.0 Phase 6: deep-link target. Called by the panel after a section * strategy banner click navigates here with ms_action=open_. * Scrolls the requested settings section into view; no-op if not found. */ public scrollToSection(target: string): void { requestAnimationFrame(() => { const sr = this.shadowRoot; if (!sr) return; const el = sr.querySelector(`[data-section="${target}"]`) ?? sr.querySelector(`[data-section-alt="${target}"]`); if (el) el.scrollIntoView({ behavior: "smooth", block: "start" }); }); } // --- Section: Panel Access (1.0.44+) --- private _renderPanelAccess(L: string) { const selected = new Set(this._settings!.admin_panel_user_ids || []); const nonAdmins = this._users.filter((u) => !u.is_admin); // v2.8.4: the allowlist only takes effect while delegation is on. Default // off → content edits stay admin-only; we hide the user list so selecting // someone while nothing applies can't read as "they can now edit". const writeEnabled = this._settings!.operator_write_enabled ?? false; const toggle = (uid: string, on: boolean): void => { const next = new Set(selected); if (on) next.add(uid); else next.delete(uid); this._updateSetting("admin_panel_user_ids", [...next]); }; return html`

${t("settings_panel_access", L)} ${writeEnabled && selected.size > 0 ? html`${selected.size}` : nothing}

${t("settings_panel_access_desc", L)}

${!writeEnabled ? nothing : nonAdmins.length === 0 ? html`
${t("no_non_admin_users", L)}
` : nonAdmins.map((u) => html` `)}
`; } // --- Section: Features --- private _renderFeatures(L: string) { const f = this._settings!.features; // data-section="settings" makes this the default landing target for // generic "open settings" deep links. Groups CRUD lives in the global // options flow (Configure button on the integration entry) so a // "groups" target also lands here as the closest match. const items: { key: keyof AdvancedFeatures; settingKey: string; label: string; desc: string }[] = [ { key: "adaptive", settingKey: "advanced_adaptive_visible", label: t("feat_adaptive", L), desc: t("feat_adaptive_desc", L) }, { key: "predictions", settingKey: "advanced_predictions_visible", label: t("feat_predictions", L), desc: t("feat_predictions_desc", L) }, { key: "seasonal", settingKey: "advanced_seasonal_visible", label: t("feat_seasonal", L), desc: t("feat_seasonal_desc", L) }, { key: "environmental", settingKey: "advanced_environmental_visible", label: t("feat_environmental", L), desc: t("feat_environmental_desc", L) }, { key: "budget", settingKey: "advanced_budget_visible", label: t("feat_budget", L), desc: t("feat_budget_desc", L) }, { key: "groups", settingKey: "advanced_groups_visible", label: t("feat_groups", L), desc: t("feat_groups_desc", L) }, { key: "checklists", settingKey: "advanced_checklists_visible", label: t("feat_checklists", L), desc: t("feat_checklists_desc", L) }, { key: "schedule_time", settingKey: "advanced_schedule_time_visible", label: t("feat_schedule_time", L), desc: t("feat_schedule_time_desc", L) }, { key: "completion_actions", settingKey: "advanced_completion_actions_visible", label: t("feat_completion_actions", L), desc: t("feat_completion_actions_desc", L) }, ]; return html`

${t("settings_features", L)}

${t("settings_features_desc", L)}

${items.map((item) => html` `)}
`; } // --- Section: Objects table columns (#67) --- // --- Section: Template gallery curation (v2.21) --- @state() private _allTemplates: Array<{ id: string; name: string; category: string; disabled?: boolean }> = []; @state() private _templateCategories: Record> = {}; @state() private _tplOpenGroups: Set = new Set(); // One-shot request guard: keyed on a plain flag, NOT on the result being // non-empty — an empty catalog answer would otherwise re-trigger the load // from render() forever (Lit update loop; caught by the settings tests). private _templatesRequested = false; private async _loadTemplates(): Promise { if (this._templatesRequested) return; this._templatesRequested = true; try { const res = await this.hass.connection.sendMessagePromise<{ templates: Array<{ id: string; name: string; category: string; disabled?: boolean }>; categories: Record>; }>({ type: "maintenance_supporter/templates", language: this._lang, }); this._allTemplates = res.templates || []; this._templateCategories = res.categories || {}; } catch { /* section renders empty; retried on next open */ } } private _renderTemplateToggles(L: string) { this._loadTemplates(); const hidden = new Set(this._settings!.disabled_template_ids || []); // v2.27: cluster by category (declaration order = display order) so the // growing catalog stays scannable — each group gets a header with its // icon, an enabled/total count and a toggle-all checkbox. const byCat = new Map(); for (const catId of Object.keys(this._templateCategories)) byCat.set(catId, []); for (const tpl of this._allTemplates) { if (!byCat.has(tpl.category)) byCat.set(tpl.category, []); byCat.get(tpl.category)!.push(tpl); } const catName = (catId: string) => (this._templateCategories[catId]?.["name_" + L] as string) || (this._templateCategories[catId]?.name_en as string) || catId; return html`

${t("settings_templates_label", L)}

${t("settings_templates_hint", L)}

${[...byCat.entries()].filter(([, tpls]) => tpls.length > 0).map(([catId, tpls]) => { const enabled = tpls.filter((tpl) => !hidden.has(tpl.id)).length; // Collapsed by default (user request): the gallery lists 30+ // templates — folded groups with an enabled/total count keep the // settings page short; expand only what you're curating. const open = this._tplOpenGroups.has(catId); return html`
this._toggleTplGroupOpen(catId)} @keydown=${(e: KeyboardEvent) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); this._toggleTplGroupOpen(catId); } }} > ${catName(catId)} ${enabled}/${tpls.length} e.stopPropagation()} @change=${(e: Event) => this._toggleTemplateGroup(tpls.map((tpl) => tpl.id), (e.target as HTMLInputElement).checked)} />
${open ? tpls.map((tpl) => html` `) : nothing}
`; })}
`; } private _toggleTemplate(id: string, visible: boolean): void { const hidden = new Set(this._settings!.disabled_template_ids || []); if (visible) hidden.delete(id); else hidden.add(id); this._updateSetting("disabled_template_ids", [...hidden]); } /** Expand/collapse one gallery group (collapsed by default). */ private _toggleTplGroupOpen(catId: string): void { const next = new Set(this._tplOpenGroups); if (next.has(catId)) next.delete(catId); else next.add(catId); this._tplOpenGroups = next; } /** Toggle-all for one category group in the template gallery. */ private _toggleTemplateGroup(ids: string[], visible: boolean): void { const hidden = new Set(this._settings!.disabled_template_ids || []); for (const id of ids) { if (visible) hidden.delete(id); else hidden.add(id); } this._updateSetting("disabled_template_ids", [...hidden]); } private _renderObjectsColumns(L: string) { const selected = sanitizeColumns(this._settings!.objects_table_columns); return html`

${t("objects_table_columns_label", L)}

${t("objects_table_columns_hint", L)}

${OBJECT_COLUMNS.map((col) => html` `)}
`; } private _toggleColumn(key: string, on: boolean): void { const current = new Set(sanitizeColumns(this._settings!.objects_table_columns)); if (on) current.add(key); else current.delete(key); // Persist in canonical order; required columns are always kept. const next = OBJECT_COLUMNS .filter((c) => c.required || current.has(c.key)) .map((c) => c.key); this._updateSetting("objects_table_columns", next); } // --- Section: General --- private _renderGeneral(L: string) { const g = this._settings!.general; // Suggestible notify targets for the picker below come from the backend // (build_notify_targets → general.notify_targets): legacy notify services + // notify entities minus the generic send_message, plus the current saved // value. Sharing the server list keeps this picker in lockstep with the // options-flow dropdown. The keeps the input free-text so // custom / not-yet-loaded values still work. const notifyServices = g.notify_targets ?? []; const b = this._settings!.budget; return html`

${t("settings_general", L)}

${g.panel_enabled ? html` ` : ""} ${g.notifications_enabled ? html`
${t("test_notification", L)}
` : nothing}
`; } // --- Section: Notifications --- private _renderNotifications(L: string) { const n = this._settings!.notifications; const a = this._settings!.actions; return html`

${t("settings_notifications", L)}

${n.due_soon_enabled ? html` ` : nothing} ${n.overdue_enabled ? html` ` : nothing} ${n.triggered_enabled ? html` ` : nothing} ${n.quiet_hours_enabled ? html`
${t("settings_quiet_start", L)} this._updateSetting("quiet_hours_start", (e.target as HTMLInputElement).value)} />
${t("settings_quiet_end", L)} this._updateSetting("quiet_hours_end", (e.target as HTMLInputElement).value)} />
` : nothing} ${n.bundling_enabled ? html` ` : nothing}
${t("settings_reminder_leads_hint", L)}
${t("settings_notify_scope_hint", L)}

${t("settings_actions", L)}

${a.snooze_enabled ? html` ` : nothing}
${t("settings_weekly_digest_hint", L)}
${a.warranty_reminder_enabled ? html` ` : nothing}
${t("settings_warranty_reminder_hint", L)}
`; } // --- Section: Budget --- private _renderBudget(L: string) { const b = this._settings!.budget; return html`

${t("settings_budget", L)}

${b.alerts_enabled ? html` ` : nothing}
`; } // --- Section: Archive & retention (v2.10.0) --- private _renderArchive(L: string) { const a = this._settings!.archive ?? { oneoff_days: 14, delete_archived_oneoff_days: 0 }; return html`

${t("settings_archive", L)}

${t("settings_archive_desc", L)}

`; } // --- Section: Import/Export --- // --- Section: Vacation mode (v1.2.0) --- private _renderVacation(L: string) { const isStale = this._vacEnabled && !this._vacIsActive && this._vacWindowEnd && new Date(this._vacWindowEnd) < new Date(); const exemptCount = this._vacExempt.size; return html`

${t("vacation_title", L)} ${this._vacIsActive ? html`${t("vacation_active", L)}` : nothing} ${isStale ? html`${t("vacation_ended", L)}` : nothing}

${t("vacation_desc", L)}

${t("vacation_exempt_title", L)} ${exemptCount > 0 ? html`${exemptCount}` : nothing}

${t("vacation_exempt_desc", L)}

${this._vacAllTasks.length === 0 ? html`` : html`
${this._renderVacationTaskList(L)}
`}
${this._vacStart && this._vacEnd ? html`
${this._vacPreview.length > 0 ? html`${this._vacPreview.length} ${t("vacation_preview_affected", L)}` : nothing}
${this._vacPreview.length > 0 ? this._renderVacationPreview(L) : nothing} ` : nothing} ${this._vacIsActive || isStale ? html`` : nothing}
`; } private _renderVacationTaskList(L: string) { // Group by object_name; within each object sort by task_name (alphabetical, #40-style) const byObj = new Map(); for (const t of this._vacAllTasks) { const arr = byObj.get(t.object_name) || []; arr.push(t); byObj.set(t.object_name, arr); } const sortedObjs = [...byObj.entries()] .sort(([a], [b]) => a.localeCompare(b)); return sortedObjs.map(([objName, tasks]) => html`
${objName || t("no_objects", L)}
${tasks .sort((a, b) => a.task_name.localeCompare(b.task_name)) .map((task) => html` `)}
`); } private _renderVacationPreview(L: string) { return html`
${this._vacPreview.map((row) => { const eventLabel = row.events.map((e) => { const statusKey = `vacation_event_${e.status}`; return `${e.date} (${t(statusKey, L)})`; }).join(" · "); const isExempt = !row.will_suppress; return html`
${row.object_name} · ${row.task_name} ${row.kind === "sensor_based" ? html`${t("vacation_sensor_based", L)}` : nothing}
${eventLabel}
${row.kind === "time_based" ? html`` : nothing}
`; })}
`; } // --- Vacation actions --- private async _loadAllTasksForVacation(): Promise { try { const result = await this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/objects", }) as { objects: Array<{ entry_id: string; object: { name: string }; tasks: Array<{ id: string; name: string }>; }> }; const flat: typeof this._vacAllTasks = []; for (const obj of result.objects || []) { for (const t of obj.tasks || []) { flat.push({ entry_id: obj.entry_id, object_name: obj.object.name || "", task_id: t.id, task_name: t.name || "", }); } } this._vacAllTasks = flat; } catch { this._showToast(t("action_error", this._lang)); } } private async _saveVacation(patch: Record): Promise { if (this._vacSaving) return; this._vacSaving = true; try { const result = await this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/vacation/update", ...patch, }) as SettingsResponse["vacation"]; // Server is the source of truth — re-hydrate from response. this._vacEnabled = result.enabled; this._vacStart = result.start || ""; this._vacEnd = result.end || ""; this._vacBuffer = result.buffer_days; this._vacExempt = new Set(result.exempt_task_ids || []); this._vacIsActive = result.is_active; this._vacWindowEnd = result.window_end; // Notify the panel (so the Vacation tab can appear/disappear) this.dispatchEvent(new CustomEvent("settings-changed")); } catch (e: unknown) { const msg = (e as { message?: string })?.message || t("action_error", this._lang); this._showToast(msg); } finally { this._vacSaving = false; } } private _toggleVacationEnabled(on: boolean): void { this._saveVacation({ enabled: on }); } private _setVacationDate(which: "start" | "end", value: string): void { const patch: Record = {}; patch[which] = value || null; this._saveVacation(patch); } private _setVacationBuffer(value: number): void { if (value < 0 || value > 14) return; this._saveVacation({ buffer_days: value }); } private _toggleVacationExempt(taskId: string, on: boolean): void { const next = new Set(this._vacExempt); if (on) next.add(taskId); else next.delete(taskId); this._saveVacation({ exempt_task_ids: [...next] }); } private async _loadVacationPreview(): Promise { this._vacPreviewLoading = true; try { const result = await this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/vacation/preview", }) as { rows: VacationPreviewRow[] }; this._vacPreview = result.rows || []; } catch { this._showToast(t("action_error", this._lang)); } finally { this._vacPreviewLoading = false; } } private async _previewActionComplete(row: VacationPreviewRow): Promise { try { await this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/task/complete", entry_id: row.entry_id, task_id: row.task_id, }); this._showToast(t("vacation_marked_complete", this._lang)); await this._loadVacationPreview(); } catch { this._showToast(t("action_error", this._lang)); } } private async _previewActionSkip(row: VacationPreviewRow): Promise { try { await this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/task/skip", entry_id: row.entry_id, task_id: row.task_id, reason: "Skipped before vacation", }); this._showToast(t("vacation_marked_skip", this._lang)); await this._loadVacationPreview(); } catch { this._showToast(t("action_error", this._lang)); } } private async _endVacationNow(): Promise { try { const result = await this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/vacation/end_now", }) as SettingsResponse["vacation"]; this._vacEnabled = result.enabled; this._vacEnd = result.end || ""; this._vacIsActive = result.is_active; this._vacWindowEnd = result.window_end; this.dispatchEvent(new CustomEvent("settings-changed")); this._showToast(t("vacation_ended", this._lang)); } catch { this._showToast(t("action_error", this._lang)); } } // --- Section: Print QR codes (v1.1.0) --- private _renderPrintQr(L: string) { const selectedCount = this._qrSelectedEntries.size || this._qrObjects.length; const actionCount = this._qrActions.size; const estimatedQrs = selectedCount * actionCount; const overLimit = estimatedQrs > 200; return html`

${t("qr_print_title", L)}

${t("qr_print_desc", L)}

${!this._qrObjectsLoaded ? html`` : html`
${t("qr_print_filter", L)}
${t("qr_print_objects", L)}
${this._qrObjects.length === 0 ? html`
${t("no_objects", L)}
` : this._qrObjects.map((obj) => html` `)}
${t("qr_print_actions", L)}
${["view", "complete", "skip"].map((a) => html` `)}
${t("qr_print_url_mode", L)}
${t("qr_print_estimate", L)}: ${estimatedQrs} ${overLimit ? html` — ${t("qr_print_over_limit", L)}` : nothing}
${this._qrBatchResults.length > 0 ? html`
${this._qrBatchResults.length} ${t("qr_print_ready", L)}
${this._qrBatchResults.map((q) => html`
${unsafeHTML(q.svg)}
${q.object_name}
${q.task_name}
${t("qr_action_" + q.action, L)}
`)}
` : nothing} `}
`; } private async _loadQrObjects(): Promise { try { const result = await this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/objects", }) as { objects: Array<{ entry_id: string; object: { name: string }; tasks: unknown[] }> }; this._qrObjects = (result.objects || []).map((o) => ({ entry_id: o.entry_id, name: o.object.name, task_count: (o.tasks || []).length, })).sort((a, b) => a.name.localeCompare(b.name)); this._qrObjectsLoaded = true; } catch { this._showToast(t("action_error", this._lang)); } } private _toggleQrObject(entryId: string, on: boolean): void { const next = new Set(this._qrSelectedEntries); // First toggle off "all implicit" by materialising the current all-selected state if (next.size === 0) { for (const o of this._qrObjects) next.add(o.entry_id); } if (on) next.add(entryId); else next.delete(entryId); // If user re-selects everything, collapse back to "empty = all" if (next.size === this._qrObjects.length) next.clear(); this._qrSelectedEntries = next; } private _toggleQrAction(action: string, on: boolean): void { const next = new Set(this._qrActions); if (on) next.add(action); else next.delete(action); this._qrActions = next; } private async _generateBatch(): Promise { this._qrBatchLoading = true; this._qrBatchResults = []; try { const msg: Record = { type: "maintenance_supporter/qr/batch_generate", actions: [...this._qrActions], url_mode: this._qrUrlMode, }; if (this._qrSelectedEntries.size > 0) { msg.entry_ids = [...this._qrSelectedEntries]; } const result = await this.hass.connection.sendMessagePromise<{ qrs: Array<{ entry_id: string; task_id: string; object_name: string; task_name: string; action: string; svg: string; }>; }>(msg); this._qrBatchResults = result.qrs || []; if (this._qrBatchResults.length === 0) { this._showToast(t("qr_print_empty", this._lang)); } } catch (e: unknown) { const msg = (e as { message?: string })?.message || t("action_error", this._lang); this._showToast(msg); } finally { this._qrBatchLoading = false; } } private _printQrs(): void { // Open a clean popup window with only the QR grid + print stylesheet. // The earlier `window.print()` approach inherited the full HA shell // (sidebar, top bar, menu) because @media print rules in this Lit // component's shadow DOM don't reach those outer elements. if (this._qrBatchResults.length === 0) return; const L = this._lang; const cells = this._qrBatchResults.map((q) => { const actionLabel = t("qr_action_" + q.action, L); // Each cell: SVG + 3 label lines (object / task / action). The SVG // string already comes from the backend; embed verbatim. return `
${q.svg}
${this._escapeHtml(q.object_name)}
${this._escapeHtml(q.task_name)}
${this._escapeHtml(actionLabel)}
`; }).join(""); const title = t("qr_print_title", L); const html = ` ${this._escapeHtml(title)}

${this._escapeHtml(title)} — ${this._qrBatchResults.length}

${cells}
`; const win = window.open("", "_blank", "width=900,height=1100"); if (!win) { // Popup blocker — fall back to the legacy in-place print so the user // still gets *something* (with the HA shell drawback they reported). window.print(); return; } win.document.open(); win.document.write(html); win.document.close(); } private _escapeHtml(s: string): string { return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'", }[c] as string)); } private _renderImportExport(L: string) { return html`

${t("settings_import_export", L)}

${!this._exportObjectsLoaded ? html`` : html`
${t("settings_export_selection", L)}
${this._exportObjects.length === 0 ? html`
${t("no_objects", L)}
` : this._exportObjects.map((obj) => html` `)}
`}

${t("settings_docs_archive", L)}

${t("settings_docs_archive_hint", L)}

`; } // --- Export / Import actions --- private get _selectedEntryIds(): string[] | undefined { return this._exportSelectedEntries.size ? [...this._exportSelectedEntries] : undefined; } private async _loadExportObjects(): Promise { try { const result = await this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/objects", }) as { objects: Array<{ entry_id: string; object: { name: string }; tasks: unknown[] }> }; this._exportObjects = (result.objects || []).map((o) => ({ entry_id: o.entry_id, name: o.object.name, task_count: (o.tasks || []).length, })).sort((a, b) => a.name.localeCompare(b.name)); this._exportObjectsLoaded = true; } catch { this._showToast(t("action_error", this._lang)); } } private _toggleExportObject(entryId: string, on: boolean): void { const next = new Set(this._exportSelectedEntries); if (next.size === 0) { for (const o of this._exportObjects) next.add(o.entry_id); } if (on) next.add(entryId); else next.delete(entryId); if (next.size === this._exportObjects.length) next.clear(); this._exportSelectedEntries = next; } private async _exportJson(): Promise { try { const ids = this._selectedEntryIds; const result = await this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/export", format: "json", include_history: this._includeHistory, ...(ids ? { entry_ids: ids } : {}), }) as { data: string }; const ts = new Date().toISOString().slice(0, 10); this._downloadFile(result.data, `maintenance_export_${ts}.json`, "application/json"); this._showToast(t("settings_export_success", this._lang)); } catch { this._showToast(t("action_error", this._lang)); } } private async _exportYaml(): Promise { try { const ids = this._selectedEntryIds; const result = await this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/export", format: "yaml", include_history: this._includeHistory, ...(ids ? { entry_ids: ids } : {}), }) as { data: string }; const ts = new Date().toISOString().slice(0, 10); this._downloadFile(result.data, `maintenance_export_${ts}.yaml`, "application/yaml"); this._showToast(t("settings_export_success", this._lang)); } catch { this._showToast(t("action_error", this._lang)); } } private async _exportCsv(): Promise { try { const ids = this._selectedEntryIds; const result = await this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/csv/export", ...(ids ? { entry_ids: ids } : {}), }) as { csv: string }; const ts = new Date().toISOString().slice(0, 10); this._downloadFile(result.csv, `maintenance_export_${ts}.csv`, "text/csv"); this._showToast(t("settings_export_success", this._lang)); } catch { this._showToast(t("action_error", this._lang)); } } private async _importCsvAction(): Promise { const content = this._importCsv.trim(); if (!content) return; this._importLoading = true; try { // CSV exports start with the "object_name" header; anything else // (JSON `{`/`[` or YAML `version:`) goes to the structured importer, // which parses JSON and YAML alike. const isCsv = content.startsWith("object_name"); const result = await this.hass.connection.sendMessagePromise( isCsv ? { type: "maintenance_supporter/csv/import", csv_content: content } : { type: "maintenance_supporter/json/import", json_content: content } ) as { created: number }; const count = result.created ?? 0; this._showToast(t("settings_import_success", this._lang).replace("{count}", String(count))); this._importCsv = ""; this.dispatchEvent(new CustomEvent("settings-changed")); } catch { this._showToast(t("action_error", this._lang)); } this._importLoading = false; } // --- Documents archive (ZIP with file contents) --- private async _exportDocsArchive(): Promise { this._docArchiveLoading = true; try { const raw = this._selectedEntryIds; const q = raw ? `?entry_ids=${encodeURIComponent(raw.join(","))}` : ""; const signed = await this.hass.connection.sendMessagePromise<{ path: string }>({ type: "auth/sign_path", path: `/api/maintenance_supporter/documents/archive${q}`, expires: 300, }); const a = document.createElement("a"); a.href = signed.path; a.download = "maintenance-documents.zip"; document.body.appendChild(a); a.click(); a.remove(); } catch { this._showToast(t("action_error", this._lang)); } this._docArchiveLoading = false; } private _triggerDocsArchiveImport(): void { const input = this.renderRoot.querySelector(".docs-archive-file") as HTMLInputElement | null; input?.click(); } private async _importDocsArchive(e: Event): Promise { const input = e.target as HTMLInputElement; const file = input.files?.[0]; if (!file) return; this._docArchiveLoading = true; try { const form = new FormData(); form.append("file", file, file.name); const resp = await fetch("/api/maintenance_supporter/documents/archive", { method: "POST", headers: { Authorization: `Bearer ${this.hass.auth?.data?.access_token ?? ""}` }, body: form, }); if (!resp.ok) { this._showToast(t("action_error", this._lang)); } else { const result = (await resp.json()) as { blobs_written: number; documents_created: number }; this._showToast( t("settings_docs_import_success", this._lang) .replace("{blobs}", String(result.blobs_written ?? 0)) .replace("{docs}", String(result.documents_created ?? 0)) ); this.dispatchEvent(new CustomEvent("settings-changed")); } } catch { this._showToast(t("action_error", this._lang)); } input.value = ""; this._docArchiveLoading = false; } // --- Styles --- static styles = css` :host { display: block; } .settings-loading { text-align: center; padding: 32px; color: var(--secondary-text-color); } .settings-section { margin-bottom: 24px; padding: 16px; background: var(--card-background-color, #fff); border-radius: 12px; border: 1px solid var(--divider-color, #e0e0e0); } .settings-section h3 { margin: 0 0 4px 0; font-size: 16px; } .section-desc { font-size: 13px; color: var(--secondary-text-color); margin: 0 0 16px 0; } .setting-row { display: flex; align-items: center; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid var(--divider-color, #e0e0e0); cursor: pointer; gap: 12px; } /* v2.27: template gallery clustered by category */ .tpl-group { margin-top: 14px; } .tpl-group-head { display: flex; align-items: center; gap: 8px; padding: 8px 0 6px; border-bottom: 2px solid var(--divider-color, #e0e0e0); cursor: pointer; font-weight: 600; } .tpl-group-head ha-icon { --mdc-icon-size: 18px; color: var(--primary-color); } .tpl-group-head .tpl-chevron { color: var(--secondary-text-color); } .tpl-group-head:focus-visible { outline: 2px solid var(--primary-color); outline-offset: 2px; } .tpl-group-name { flex: 1; } .tpl-group-count { font-size: 12px; color: var(--secondary-text-color); font-weight: 400; } .tpl-row { padding-left: 26px; } .setting-row:last-child { border-bottom: none; } .setting-row.sub-row { padding-left: 16px; } .setting-label { font-size: 14px; display: block; } .setting-desc { font-size: 12px; color: var(--secondary-text-color); display: block; } .setting-row input[type="checkbox"] { width: 18px; height: 18px; flex-shrink: 0; } .setting-row input[type="number"], .setting-row input[type="text"], .setting-row input[type="time"] { width: 120px; padding: 6px 8px; border: 1px solid var(--divider-color, #e0e0e0); border-radius: 4px; background: var(--card-background-color, #fff); color: var(--primary-text-color); font-size: 14px; flex-shrink: 0; } .setting-row input[type="number"] { text-align: right; } .setting-row select { padding: 6px 8px; border: 1px solid var(--divider-color, #e0e0e0); border-radius: 4px; background: var(--card-background-color, #fff); color: var(--primary-text-color); font-size: 14px; flex-shrink: 0; } .settings-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; } .settings-actions button { padding: 8px 16px; border-radius: 8px; border: 1px solid var(--divider-color, #e0e0e0); background: var(--card-background-color, #fff); color: var(--primary-text-color); cursor: pointer; font-size: 14px; } .settings-actions button:hover { background: var(--secondary-background-color, #f5f5f5); } .settings-actions button[disabled] { opacity: 0.5; cursor: not-allowed; } .export-history-toggle { display: flex; align-items: center; gap: 6px; font-size: 13px; cursor: pointer; } .export-history-toggle input { width: 16px; height: 16px; } .import-section { margin-top: 16px; } .import-area { width: 100%; min-height: 120px; padding: 8px; border: 1px solid var(--divider-color, #e0e0e0); border-radius: 8px; font-family: monospace; font-size: 12px; resize: vertical; background: var(--card-background-color, #fff); color: var(--primary-text-color); box-sizing: border-box; } .settings-toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); background: var(--primary-color, #03a9f4); color: #fff; padding: 10px 24px; border-radius: 8px; font-size: 14px; z-index: 1000; box-shadow: 0 2px 8px rgba(0,0,0,.3); animation: toast-in .3s ease; } @keyframes toast-in { from { opacity: 0; transform: translateX(-50%) translateY(16px); } to { opacity: 1; transform: translateX(-50%) translateY(0); } } /* ─── Vacation mode section (v1.2.0) ─── */ .vacation-section h3 { display: flex; align-items: center; gap: 8px; } .vac-badge { font-size: 11px; font-weight: 600; letter-spacing: 0.4px; text-transform: uppercase; padding: 2px 8px; border-radius: 10px; } .vac-badge.active { background: var(--success-color, #4caf50); color: #fff; } .vac-badge.stale { background: var(--warning-color, #ff9800); color: #fff; } .vac-toggle { display: flex; align-items: center; gap: 8px; cursor: pointer; margin: 8px 0 12px; } .vac-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px; margin-bottom: 12px; } .vac-field { display: flex; flex-direction: column; gap: 4px; } .vac-field input { padding: 6px 8px; border: 1px solid var(--divider-color); border-radius: 4px; background: var(--card-background-color, #fff); color: var(--primary-text-color); } .vac-exempt-panel { border: 1px solid var(--divider-color); border-radius: 6px; padding: 10px; margin: 12px 0; } .vac-exempt-panel summary { cursor: pointer; font-weight: 500; display: flex; align-items: center; gap: 8px; } .section-badge { background: var(--primary-color, #03a9f4); color: #fff; font-size: 11px; font-weight: 600; padding: 1px 8px; border-radius: 10px; } .vac-task-list { max-height: 280px; overflow-y: auto; margin-top: 8px; } .vac-task-group { margin: 8px 0; } .vac-task-group-name { font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; color: var(--secondary-text-color); padding: 4px 0; } .vac-task-row { display: flex; align-items: center; gap: 8px; padding: 4px 8px; cursor: pointer; border-radius: 4px; } .vac-task-row:hover { background: var(--secondary-background-color, rgba(127,127,127,0.1)); } .vac-preview-toolbar { display: flex; align-items: center; gap: 12px; margin: 12px 0 8px; } .vac-preview-count { color: var(--secondary-text-color); font-size: 13px; } .vac-preview-list { display: flex; flex-direction: column; gap: 6px; } .vac-preview-row { display: flex; gap: 12px; padding: 10px 12px; background: var(--secondary-background-color, rgba(127,127,127,0.08)); border-radius: 6px; border-left: 3px solid var(--warning-color, #ff9800); } .vac-preview-row.exempt { border-left-color: var(--success-color, #4caf50); } .vac-preview-info { flex: 1; } .vac-preview-name { font-size: 14px; } .vac-preview-kind { font-size: 11px; color: var(--secondary-text-color); margin-left: 6px; } .vac-preview-events { font-size: 12px; color: var(--secondary-text-color); margin-top: 2px; } .vac-preview-actions { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; } .vac-preview-actions button { font-size: 12px; padding: 4px 10px; } .vac-notify-on { background: var(--success-color, #4caf50) !important; color: #fff; } .vac-end-now { margin-top: 12px; background: var(--error-color, #f44336); color: #fff; } /* ─── Print QR codes section (v1.1.0) ─── */ .qr-filter-panel { border: 1px solid var(--divider-color); border-radius: 6px; padding: 12px; margin-top: 8px; } .qr-filter-panel > summary { cursor: pointer; font-weight: 500; } .qr-filter-group { margin-top: 12px; } .qr-filter-label { font-size: 11px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.4px; color: var(--secondary-text-color); margin-bottom: 4px; } .qr-object-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 4px 12px; max-height: 240px; overflow-y: auto; } .qr-object-row { display: flex; align-items: center; gap: 6px; padding: 4px 6px; cursor: pointer; border-radius: 4px; } .qr-object-row:hover { background: var(--secondary-background-color, rgba(127,127,127,0.1)); } .qr-object-row > span:nth-of-type(1) { flex: 1; } .qr-task-count { color: var(--secondary-text-color); font-size: 12px; font-variant-numeric: tabular-nums; } .qr-action-chips { display: flex; gap: 6px; flex-wrap: wrap; } .qr-action-chip { display: inline-flex; align-items: center; gap: 4px; padding: 4px 10px; border-radius: 14px; border: 1px solid var(--divider-color); cursor: pointer; user-select: none; } .qr-action-chip.active { background: var(--primary-color, #03a9f4); color: #fff; border-color: transparent; } .qr-action-chip input { accent-color: currentColor; } .qr-filter-actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; } .qr-estimate { font-size: 13px; } .qr-estimate.error { color: var(--error-color, #f44336); } .qr-results-toolbar { display: flex; justify-content: space-between; align-items: center; margin-top: 16px; padding: 8px 12px; background: var(--secondary-background-color, rgba(127,127,127,0.1)); border-radius: 6px; } .qr-print-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 16px; margin-top: 16px; } .qr-print-cell { border: 1px solid var(--divider-color); border-radius: 6px; padding: 8px; display: flex; flex-direction: column; align-items: center; text-align: center; background: #fff; color: #000; } .qr-print-cell .qr-svg { width: 100%; max-width: 160px; } .qr-print-cell .qr-svg svg { width: 100%; height: auto; display: block; } .qr-label { margin-top: 6px; font-size: 11px; line-height: 1.3; } .qr-label-obj { font-weight: 600; } .qr-label-task { color: #444; } .qr-label-action { margin-top: 2px; text-transform: uppercase; letter-spacing: 0.5px; font-size: 10px; color: #777; } /* ─── Print stylesheet ─── */ @media print { /* Strip everything except the QR grid itself */ :host { color: #000; background: #fff; } .qr-print-section h3, .qr-print-section .section-desc, .qr-filter-panel, .qr-results-toolbar, .settings-section:not(.qr-print-section), .settings-toast { display: none !important; } .qr-print-section { padding: 0; margin: 0; } .qr-print-grid { grid-template-columns: repeat(3, 1fr); gap: 12mm 8mm; margin: 0; } .qr-print-cell { border: none; padding: 0; page-break-inside: avoid; } .qr-print-cell .qr-svg { max-width: 48mm; } .qr-label { font-size: 9pt; } } `; } customElements.define("maintenance-settings-view", MaintenanceSettingsView);