Updated Scheduler and Maintainance Apps
This commit is contained in:
+18
-6
@@ -105,6 +105,7 @@ function ctx(overrides: Partial<TaskDetailContext> = {}): TaskDetailContext {
|
||||
openQr: () => undefined,
|
||||
duplicateTask: () => undefined,
|
||||
promptReset: () => undefined,
|
||||
promptPostpone: () => undefined,
|
||||
snoozeTask: () => undefined,
|
||||
printWorksheet: () => undefined,
|
||||
deleteTask: () => undefined,
|
||||
@@ -162,21 +163,32 @@ describe("task-detail renderer", () => {
|
||||
expect(labels.some((l) => /archive/i.test(l))).to.be.false;
|
||||
});
|
||||
|
||||
it("open more-menu lists edit/duplicate/reset/snooze/worksheet/delete and fires callbacks", () => {
|
||||
it("open more-menu lists edit/duplicate/reset/postpone/snooze/worksheet/delete and fires callbacks", () => {
|
||||
const calls: string[] = [];
|
||||
const host = mount(task(), ctx({
|
||||
moreMenuOpen: true,
|
||||
closeMoreMenu: () => calls.push("close"),
|
||||
deleteTask: () => calls.push("delete"),
|
||||
promptPostpone: () => calls.push("postpone"),
|
||||
snoozeTask: () => calls.push("snooze"),
|
||||
printWorksheet: () => calls.push("worksheet"),
|
||||
}));
|
||||
const items = [...host.querySelectorAll(".popup-menu-item")];
|
||||
expect(items.length).to.equal(6);
|
||||
(items[3] as HTMLElement).click(); // snooze
|
||||
(items[4] as HTMLElement).click(); // work sheet (v2.21)
|
||||
(items[5] as HTMLElement).click(); // delete (danger)
|
||||
expect(calls).to.deep.equal(["close", "snooze", "close", "worksheet", "close", "delete"]);
|
||||
expect(items.length).to.equal(7);
|
||||
(items[3] as HTMLElement).click(); // postpone
|
||||
(items[4] as HTMLElement).click(); // snooze
|
||||
(items[5] as HTMLElement).click(); // work sheet (v2.21)
|
||||
(items[6] as HTMLElement).click(); // delete (danger)
|
||||
expect(calls).to.deep.equal(["close", "postpone", "close", "snooze", "close", "worksheet", "close", "delete"]);
|
||||
});
|
||||
|
||||
it("shows a postponed badge when the task has a due_override, and none otherwise", () => {
|
||||
const plain = mount(task(), ctx());
|
||||
expect(plain.querySelector(".postponed-badge")).to.be.null;
|
||||
const postponed = mount(task({ due_override: "2026-06-20" }), ctx());
|
||||
const badge = postponed.querySelector(".postponed-badge");
|
||||
expect(badge, "postponed badge").to.exist;
|
||||
expect(badge!.textContent).to.match(/2026|20/);
|
||||
});
|
||||
|
||||
it("tab bar switches via setActiveTab; history tab renders the timeline", () => {
|
||||
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
/** Tests for <maintenance-task-detail-view> — the task-detail sub-view as a
|
||||
* web component (incremental step over the renderers/task-detail
|
||||
* extraction). Pins the contract the component adds on top of the renderer:
|
||||
* - registers and renders into LIGHT DOM (no own shadow root) so the
|
||||
* panel's shadow-scoped styles keep matching
|
||||
* - header / breadcrumb / actions come through the component boundary
|
||||
* - callbacks in the passed TaskDetailContext still fire (Complete, tab
|
||||
* switch) — props in, panel callbacks out
|
||||
* - property changes re-render (new task object → new name in the DOM)
|
||||
* - renders nothing until both `task` and `ctx` are set
|
||||
*/
|
||||
|
||||
import { expect, fixture } from "@open-wc/testing";
|
||||
import { html } from "lit";
|
||||
import "../components/task-detail-view.js";
|
||||
import type { MaintenanceTaskDetailView } from "../components/task-detail-view.js";
|
||||
import type { TaskDetailContext } from "../renderers/task-detail.js";
|
||||
import type { MaintenanceTask } from "../types";
|
||||
import { createMockHass } from "./_test-utils.js";
|
||||
|
||||
function task(overrides: Record<string, unknown> = {}): MaintenanceTask {
|
||||
return {
|
||||
id: "t1",
|
||||
name: "Filter Wechsel",
|
||||
type: "cleaning",
|
||||
enabled: true,
|
||||
status: "due_soon",
|
||||
schedule_type: "time_based",
|
||||
interval_days: 30,
|
||||
warning_days: 7,
|
||||
days_until_due: 3,
|
||||
next_due: "2026-07-10",
|
||||
last_performed: "2026-06-10",
|
||||
times_performed: 2,
|
||||
total_cost: 50,
|
||||
average_duration: 20,
|
||||
history: [],
|
||||
checklist: [],
|
||||
is_done: false,
|
||||
archived: false,
|
||||
trigger_active: false,
|
||||
...overrides,
|
||||
} as unknown as MaintenanceTask;
|
||||
}
|
||||
|
||||
function ctx(overrides: Partial<TaskDetailContext> = {}): TaskDetailContext {
|
||||
const { hass } = createMockHass({
|
||||
handler: () => ({ documents: [] }),
|
||||
});
|
||||
return {
|
||||
lang: "en",
|
||||
hass: hass as TaskDetailContext["hass"],
|
||||
entryId: "entry1",
|
||||
taskId: "t1",
|
||||
objectName: "Pool Pump",
|
||||
objectDocUrl: null,
|
||||
isOperator: false,
|
||||
actionLoading: false,
|
||||
moreMenuOpen: false,
|
||||
activeTab: "overview",
|
||||
features: {
|
||||
adaptive: false, predictions: false, seasonal: false,
|
||||
environmental: false, budget: false, groups: false,
|
||||
checklists: false, schedule_time: false, completion_actions: false,
|
||||
},
|
||||
currencySymbol: "€",
|
||||
collapsedSections: new Set(),
|
||||
costDurationToggle: "both",
|
||||
suggestionDismissed: false,
|
||||
sparkline: {
|
||||
lang: "en",
|
||||
detailStatsData: new Map(),
|
||||
hasStatsService: false,
|
||||
isCounterEntity: () => false,
|
||||
rangeDays: 30,
|
||||
setRangeDays: () => undefined,
|
||||
hideOutliers: false,
|
||||
setHideOutliers: () => undefined,
|
||||
},
|
||||
history: {
|
||||
lang: "en",
|
||||
hass: hass as TaskDetailContext["hass"],
|
||||
filter: null,
|
||||
search: "",
|
||||
currencySymbol: "€",
|
||||
setFilter: () => undefined,
|
||||
setSearch: () => undefined,
|
||||
openEdit: () => undefined,
|
||||
},
|
||||
getUserName: () => null,
|
||||
setActiveTab: () => undefined,
|
||||
toggleSection: () => undefined,
|
||||
setCostDurationToggle: () => undefined,
|
||||
showTaskView: () => undefined,
|
||||
showObject: () => undefined,
|
||||
toggleMoreMenu: () => undefined,
|
||||
closeMoreMenu: () => undefined,
|
||||
openEdit: () => undefined,
|
||||
openComplete: () => undefined,
|
||||
promptSkip: () => undefined,
|
||||
toggleArchive: () => undefined,
|
||||
openQr: () => undefined,
|
||||
duplicateTask: () => undefined,
|
||||
promptReset: () => undefined,
|
||||
promptPostpone: () => undefined,
|
||||
snoozeTask: () => undefined,
|
||||
printWorksheet: () => undefined,
|
||||
deleteTask: () => undefined,
|
||||
applySuggestion: () => undefined,
|
||||
reanalyze: () => undefined,
|
||||
dismissSuggestion: () => undefined,
|
||||
openSeasonalOverrides: () => undefined,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function mount(t: MaintenanceTask, c: TaskDetailContext): Promise<MaintenanceTaskDetailView> {
|
||||
const el = await fixture<MaintenanceTaskDetailView>(html`
|
||||
<maintenance-task-detail-view .task=${t} .ctx=${c}></maintenance-task-detail-view>
|
||||
`);
|
||||
await el.updateComplete;
|
||||
return el;
|
||||
}
|
||||
|
||||
describe("maintenance-task-detail-view", () => {
|
||||
it("is registered as a custom element", () => {
|
||||
expect(customElements.get("maintenance-task-detail-view")).to.exist;
|
||||
});
|
||||
|
||||
it("renders into light DOM so the panel's shadow-scoped styles keep applying", async () => {
|
||||
const el = await mount(task(), ctx());
|
||||
expect(el.shadowRoot, "no own shadow root").to.equal(null);
|
||||
// The detail markup is queryable directly on the element (= light DOM).
|
||||
expect(el.querySelector(".detail-section"), "detail section in light DOM").to.exist;
|
||||
});
|
||||
|
||||
it("renders header, breadcrumb and status through the component boundary", async () => {
|
||||
const el = await mount(task(), ctx());
|
||||
expect(el.querySelector(".task-name-breadcrumb")?.textContent).to.contain("Filter Wechsel");
|
||||
expect(el.querySelector(".object-name-breadcrumb")?.textContent).to.contain("Pool Pump");
|
||||
expect(el.querySelector(".status-chip"), "status chip").to.exist;
|
||||
expect(el.querySelector(".kpi-bar"), "KPI bar").to.exist;
|
||||
});
|
||||
|
||||
it("routes the Complete action to the panel callback with the task", async () => {
|
||||
let completed: MaintenanceTask | null = null;
|
||||
const el = await mount(task(), ctx({ openComplete: (tk) => { completed = tk; } }));
|
||||
const btn = [...el.querySelectorAll(".task-header-actions ha-button")]
|
||||
.find((b) => b.textContent?.match(/complete/i)) as HTMLElement;
|
||||
expect(btn, "complete button").to.exist;
|
||||
btn.click();
|
||||
expect(completed, "openComplete received the task").to.not.equal(null);
|
||||
expect((completed as unknown as MaintenanceTask).id).to.equal("t1");
|
||||
});
|
||||
|
||||
it("switches tabs via the panel-owned setActiveTab callback", async () => {
|
||||
let tab = "";
|
||||
const el = await mount(task(), ctx({ setActiveTab: (v) => { tab = v; } }));
|
||||
const tabs = [...el.querySelectorAll(".tab-bar .tab")] as HTMLElement[];
|
||||
expect(tabs.length).to.equal(2);
|
||||
tabs[1].click();
|
||||
expect(tab).to.equal("history");
|
||||
});
|
||||
|
||||
it("re-renders when the task property changes", async () => {
|
||||
const el = await mount(task(), ctx());
|
||||
el.task = task({ name: "Pumpe entkalken" });
|
||||
await el.updateComplete;
|
||||
expect(el.querySelector(".task-name-breadcrumb")?.textContent).to.contain("Pumpe entkalken");
|
||||
});
|
||||
|
||||
it("renders nothing until both task and ctx are set", async () => {
|
||||
const el = await fixture<MaintenanceTaskDetailView>(html`
|
||||
<maintenance-task-detail-view></maintenance-task-detail-view>
|
||||
`);
|
||||
await el.updateComplete;
|
||||
expect(el.querySelector(".detail-section")).to.equal(null);
|
||||
});
|
||||
|
||||
it("hides the more-menu for operators (read-only surface preserved)", async () => {
|
||||
const el = await mount(task(), ctx({ isOperator: true }));
|
||||
expect(el.querySelector(".more-menu-wrapper")).to.equal(null);
|
||||
});
|
||||
});
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Lit tests for the seasonal-window + finite-series recurrence extras in
|
||||
* <maintenance-task-dialog>. Pins hydration from the nested schedule and the
|
||||
* outgoing `schedule` payload (backend: test_schedule.py / test_ws_io.py).
|
||||
*/
|
||||
|
||||
import { expect, fixture, html } from "@open-wc/testing";
|
||||
import "../components/task-dialog.js";
|
||||
import type { MaintenanceTaskDialog } from "../components/task-dialog";
|
||||
import { type SentMessage, createMockHass } from "./_test-utils.js";
|
||||
|
||||
async function mountDialog(): Promise<{ el: MaintenanceTaskDialog; sent: SentMessage[] }> {
|
||||
const { hass, sent } = createMockHass({
|
||||
handlers: {
|
||||
"maintenance_supporter/task/create": () => ({ task_id: "new1" }),
|
||||
"maintenance_supporter/task/update": () => ({}),
|
||||
},
|
||||
});
|
||||
const el = await fixture<MaintenanceTaskDialog>(html`
|
||||
<maintenance-task-dialog .hass=${hass}></maintenance-task-dialog>
|
||||
`);
|
||||
await el.updateComplete;
|
||||
return { el, sent };
|
||||
}
|
||||
|
||||
describe("task-dialog recurrence extras (season / finite series)", () => {
|
||||
it("hydrates season_months and a count end from the nested schedule", async () => {
|
||||
const { el } = await mountDialog();
|
||||
await el.openEdit("e", {
|
||||
id: "t1", name: "Mow", type: "custom", schedule_type: "time_based",
|
||||
interval_days: 14, interval_unit: "days", warning_days: 7, enabled: true,
|
||||
schedule: { kind: "interval", every: 14, unit: "days", season_months: [4, 5, 6], ends: { count: 6 } },
|
||||
} as any);
|
||||
await el.updateComplete;
|
||||
expect((el as any)._seasonMonths).to.deep.equal([4, 5, 6]);
|
||||
expect((el as any)._endsMode).to.equal("count");
|
||||
expect((el as any)._endsCount).to.equal("6");
|
||||
});
|
||||
|
||||
it("hydrates an until end", async () => {
|
||||
const { el } = await mountDialog();
|
||||
await el.openEdit("e", {
|
||||
id: "t1", name: "Cure", type: "custom", schedule_type: "time_based",
|
||||
interval_days: 30, warning_days: 7, enabled: true,
|
||||
schedule: { kind: "interval", every: 30, unit: "days", ends: { until: "2027-01-01" } },
|
||||
} as any);
|
||||
await el.updateComplete;
|
||||
expect((el as any)._endsMode).to.equal("until");
|
||||
expect((el as any)._endsUntil).to.equal("2027-01-01");
|
||||
});
|
||||
|
||||
it("create sends season_months + ends on an interval task's nested schedule", async () => {
|
||||
const { el, sent } = await mountDialog();
|
||||
await el.openCreate("e");
|
||||
(el as any)._name = "Mow";
|
||||
(el as any)._scheduleType = "time_based";
|
||||
(el as any)._intervalDays = "14";
|
||||
(el as any)._intervalUnit = "days";
|
||||
(el as any)._seasonMonths = [7, 4, 5];
|
||||
(el as any)._endsMode = "count";
|
||||
(el as any)._endsCount = "6";
|
||||
await (el as any)._save();
|
||||
const msg = sent.find((m) => m.type === "maintenance_supporter/task/create") as any;
|
||||
expect(msg.schedule).to.deep.equal({ kind: "interval", season_months: [4, 5, 7], ends: { count: 6 } });
|
||||
expect(msg.interval_days).to.equal(14); // flat fields still carry the cadence
|
||||
});
|
||||
|
||||
it("attaches the extras to a calendar-kind schedule too", async () => {
|
||||
const { el, sent } = await mountDialog();
|
||||
await el.openCreate("e");
|
||||
(el as any)._name = "Gutter";
|
||||
(el as any)._scheduleType = "day_of_month";
|
||||
(el as any)._domDay = "15";
|
||||
(el as any)._seasonMonths = [10, 11];
|
||||
await (el as any)._save();
|
||||
const msg = sent.find((m) => m.type === "maintenance_supporter/task/create") as any;
|
||||
expect(msg.schedule).to.deep.equal({ kind: "day_of_month", day: 15, season_months: [10, 11] });
|
||||
});
|
||||
|
||||
it("clearing season/ends on edit sends an authoritative schedule without them", async () => {
|
||||
const { el, sent } = await mountDialog();
|
||||
await el.openEdit("e", {
|
||||
id: "t1", name: "Mow", type: "custom", schedule_type: "time_based",
|
||||
interval_days: 14, warning_days: 7, enabled: true,
|
||||
schedule: { kind: "interval", every: 14, unit: "days", season_months: [4, 5], ends: { count: 3 } },
|
||||
} as any);
|
||||
(el as any)._seasonMonths = [];
|
||||
(el as any)._endsMode = "never";
|
||||
await (el as any)._save();
|
||||
const msg = sent.find((m) => m.type === "maintenance_supporter/task/update") as any;
|
||||
expect(msg.schedule).to.deep.equal({ kind: "interval" }); // no season_months, no ends
|
||||
});
|
||||
|
||||
it("shows the season chips + ends selector for recurring, not for one_time", async () => {
|
||||
const { el } = await mountDialog();
|
||||
await el.openCreate("e");
|
||||
(el as any)._scheduleType = "time_based";
|
||||
await el.updateComplete;
|
||||
expect(el.shadowRoot!.querySelectorAll(".season-chip").length).to.equal(12);
|
||||
expect(el.shadowRoot!.querySelector('select option[value="count"]')).to.exist;
|
||||
|
||||
(el as any)._scheduleType = "one_time";
|
||||
await el.updateComplete;
|
||||
expect(el.shadowRoot!.querySelectorAll(".season-chip").length).to.equal(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
/** <maintenance-task-detail-view> — the task-detail sub-view as a web
|
||||
* component. The incremental step over the renderers/task-detail
|
||||
* extraction: the panel now hands the component a task + the
|
||||
* TaskDetailContext instead of calling the render function inline, giving
|
||||
* the sub-view a real element boundary (own update lifecycle, testable in
|
||||
* isolation, future home for detail-only state).
|
||||
*
|
||||
* Deliberately renders into LIGHT DOM (createRenderRoot returns `this`):
|
||||
* the element lives inside the panel's shadow root, so every selector in
|
||||
* panel-styles.ts (.task-header, .kpi-bar, .popup-menu, …) keeps matching
|
||||
* and every dialog-opening callback still runs against the panel's shadow
|
||||
* root. Moving the ~1000 lines of detail CSS and the dialog ownership in
|
||||
* here would be the big-bang rewrite this component exists to avoid.
|
||||
*/
|
||||
|
||||
import { LitElement, html, nothing } from "lit";
|
||||
import { property } from "lit/decorators.js";
|
||||
import { renderTaskDetail, type TaskDetailContext } from "../renderers/task-detail";
|
||||
import type { MaintenanceTask } from "../types";
|
||||
|
||||
export class MaintenanceTaskDetailView extends LitElement {
|
||||
@property({ attribute: false }) public task?: MaintenanceTask;
|
||||
@property({ attribute: false }) public ctx?: TaskDetailContext;
|
||||
|
||||
protected createRenderRoot(): HTMLElement {
|
||||
return this;
|
||||
}
|
||||
|
||||
protected render(): unknown {
|
||||
if (!this.task || !this.ctx) return nothing;
|
||||
return html`${renderTaskDetail(this.task, this.ctx)}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get("maintenance-task-detail-view")) {
|
||||
customElements.define("maintenance-task-detail-view", MaintenanceTaskDetailView);
|
||||
}
|
||||
@@ -88,6 +88,12 @@ function weekdayNames(lang?: string): string[] {
|
||||
return Array.from({ length: 7 }, (_, i) => weekdayName(i, lang, "short"));
|
||||
}
|
||||
|
||||
/** Short localized month names, indexed 0=Jan … 11=Dec. */
|
||||
function monthNames(lang?: string): string[] {
|
||||
const fmt = new Intl.DateTimeFormat(lang || "en", { month: "short" });
|
||||
return Array.from({ length: 12 }, (_, i) => fmt.format(new Date(2021, i, 1)));
|
||||
}
|
||||
|
||||
export class MaintenanceTaskDialog extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@property({ type: Boolean, attribute: "checklists-enabled" }) public checklistsEnabled = false;
|
||||
@@ -122,6 +128,12 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
@state() private _domLastDay = false;
|
||||
@state() private _domBusiness = false;
|
||||
@state() private _calOffset = "0";
|
||||
// Recurrence extras (apply to interval + calendar kinds): a seasonal active
|
||||
// window and a finite-series end condition.
|
||||
@state() private _seasonMonths: number[] = [];
|
||||
@state() private _endsMode: "never" | "count" | "until" = "never";
|
||||
@state() private _endsCount = "";
|
||||
@state() private _endsUntil = "";
|
||||
@state() private _notes = "";
|
||||
@state() private _documentationUrl = "";
|
||||
@state() private _customIcon = "";
|
||||
@@ -245,6 +257,16 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
this._domLastDay = sched?.kind === "day_of_month" && sched.day === -1;
|
||||
this._domBusiness = sched?.kind === "day_of_month" && sched.business === true;
|
||||
this._calOffset = sched?.offset ? String(sched.offset) : "0";
|
||||
// Recurrence extras — read from the nested schedule wherever it lives.
|
||||
this._seasonMonths = Array.isArray(sched?.season_months) ? [...sched!.season_months] : [];
|
||||
const ends = sched?.ends;
|
||||
if (ends && typeof ends.count === "number") {
|
||||
this._endsMode = "count"; this._endsCount = String(ends.count); this._endsUntil = "";
|
||||
} else if (ends && typeof ends.until === "string") {
|
||||
this._endsMode = "until"; this._endsUntil = ends.until; this._endsCount = "";
|
||||
} else {
|
||||
this._endsMode = "never"; this._endsCount = ""; this._endsUntil = "";
|
||||
}
|
||||
this._warningDays = task.warning_days.toString();
|
||||
this._earliestCompletionDays =
|
||||
task.earliest_completion_days != null ? String(task.earliest_completion_days) : "";
|
||||
@@ -345,6 +367,10 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
this._domLastDay = false;
|
||||
this._domBusiness = false;
|
||||
this._calOffset = "0";
|
||||
this._seasonMonths = [];
|
||||
this._endsMode = "never";
|
||||
this._endsCount = "";
|
||||
this._endsUntil = "";
|
||||
this._notes = "";
|
||||
this._documentationUrl = "";
|
||||
this._customIcon = "";
|
||||
@@ -700,7 +726,7 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
} else if (CALENDAR_KINDS.includes(this._scheduleType)) {
|
||||
// Calendar kinds are sent as the nested schedule; the backend prefers
|
||||
// it over the flat fields and clears any stale interval/due_date.
|
||||
data.schedule = this._buildSchedule();
|
||||
data.schedule = { ...this._buildSchedule(), ...this._recurrenceExtras() };
|
||||
data.interval_days = null;
|
||||
if (this._taskId) data.due_date = null;
|
||||
} else {
|
||||
@@ -710,6 +736,14 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
data.interval_days = parseInt(this._intervalDays, 10);
|
||||
data.interval_unit = this._intervalUnit;
|
||||
data.interval_anchor = this._intervalAnchor;
|
||||
// Season / finite-series ride a minimal nested schedule alongside the
|
||||
// flat interval fields; the backend carries them onto the interval it
|
||||
// derives from the flat fields (they can't be expressed flatly).
|
||||
// Always send an authoritative nested schedule for interval tasks so
|
||||
// an edit that clears season/ends actually clears them.
|
||||
if (this._scheduleType === "time_based") {
|
||||
data.schedule = { kind: "interval", ...this._recurrenceExtras() };
|
||||
}
|
||||
} else if (this._taskId) {
|
||||
data.interval_days = null;
|
||||
data.interval_anchor = "completion";
|
||||
@@ -1133,6 +1167,72 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
return withOffset(schedule);
|
||||
}
|
||||
|
||||
/** The season/finite-series extras to attach to a recurring schedule. Empty
|
||||
* values are omitted, so a clean schedule stays clean (and, since the sent
|
||||
* schedule is authoritative, an omitted extra clears a previously-set one). */
|
||||
private _recurrenceExtras(): { season_months?: number[]; ends?: { count?: number; until?: string } } {
|
||||
const extras: { season_months?: number[]; ends?: { count?: number; until?: string } } = {};
|
||||
if (this._seasonMonths.length) extras.season_months = [...this._seasonMonths].sort((a, b) => a - b);
|
||||
if (this._endsMode === "count") {
|
||||
const n = parseInt(this._endsCount, 10);
|
||||
if (n >= 1) extras.ends = { count: n };
|
||||
} else if (this._endsMode === "until" && this._endsUntil) {
|
||||
extras.ends = { until: this._endsUntil };
|
||||
}
|
||||
return extras;
|
||||
}
|
||||
|
||||
private _toggleSeasonMonth(m: number): void {
|
||||
this._seasonMonths = this._seasonMonths.includes(m)
|
||||
? this._seasonMonths.filter((x) => x !== m)
|
||||
: [...this._seasonMonths, m];
|
||||
}
|
||||
|
||||
/** Seasonal window + finite-series end — shown for recurring (interval +
|
||||
* calendar) kinds, where both apply. */
|
||||
private _renderRecurrenceExtras() {
|
||||
const L = this._lang;
|
||||
const recurring = this._scheduleType === "time_based" || CALENDAR_KINDS.includes(this._scheduleType);
|
||||
if (!recurring) return nothing;
|
||||
const months = monthNames(L);
|
||||
return html`
|
||||
<label class="field-label">${t("season_window_label", L)}</label>
|
||||
<div class="field-help">${t("season_window_hint", L)}</div>
|
||||
<div class="weekday-chips season-chips">
|
||||
${months.map((name, i) => html`
|
||||
<button
|
||||
type="button"
|
||||
class="season-chip ${this._seasonMonths.includes(i + 1) ? "selected" : ""}"
|
||||
@click=${() => this._toggleSeasonMonth(i + 1)}
|
||||
>${name}</button>`)}
|
||||
</div>
|
||||
|
||||
<label class="field-label">${t("series_end_label", L)}</label>
|
||||
<div class="select-row">
|
||||
<select .value=${this._endsMode}
|
||||
@change=${(e: Event) => (this._endsMode = (e.target as HTMLSelectElement).value as "never" | "count" | "until")}>
|
||||
<option value="never" ?selected=${this._endsMode === "never"}>${t("series_end_never", L)}</option>
|
||||
<option value="count" ?selected=${this._endsMode === "count"}>${t("series_end_after_count", L)}</option>
|
||||
<option value="until" ?selected=${this._endsMode === "until"}>${t("series_end_until", L)}</option>
|
||||
</select>
|
||||
</div>
|
||||
${this._endsMode === "count" ? html`
|
||||
<ms-textfield
|
||||
label="${t("series_end_count_label", L)}"
|
||||
type="number" min="1"
|
||||
.value=${this._endsCount}
|
||||
@input=${(e: Event) => (this._endsCount = (e.target as HTMLInputElement).value)}
|
||||
></ms-textfield>` : nothing}
|
||||
${this._endsMode === "until" ? html`
|
||||
<ms-textfield
|
||||
label="${t("series_end_until_label", L)}"
|
||||
type="date"
|
||||
.value=${this._endsUntil}
|
||||
@input=${(e: Event) => (this._endsUntil = (e.target as HTMLInputElement).value)}
|
||||
></ms-textfield>` : nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
/** Per-kind field groups for the calendar recurrence kinds. */
|
||||
private _renderCalendarFields() {
|
||||
const L = this._lang;
|
||||
@@ -1415,6 +1515,7 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
></ms-textfield>
|
||||
`
|
||||
: nothing}
|
||||
${this._renderRecurrenceExtras()}
|
||||
<ms-textfield
|
||||
label="${t("warning_days", L)}"
|
||||
type="number"
|
||||
@@ -1772,6 +1873,20 @@ export class MaintenanceTaskDialog extends LitElement {
|
||||
color: var(--text-primary-color, #fff);
|
||||
border-color: var(--primary-color, #03a9f4);
|
||||
}
|
||||
.season-chip {
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: 16px;
|
||||
background: var(--card-background-color, #fff);
|
||||
color: var(--primary-text-color);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.season-chip.selected {
|
||||
background: var(--primary-color, #03a9f4);
|
||||
color: var(--text-primary-color, #fff);
|
||||
border-color: var(--primary-color, #03a9f4);
|
||||
}
|
||||
.error {
|
||||
color: var(--error-color, #f44336);
|
||||
font-size: 13px;
|
||||
|
||||
@@ -692,5 +692,19 @@
|
||||
"worksheet_manual_excerpt": "Výňatek z manuálu",
|
||||
"worksheet_pages": "strany",
|
||||
"worksheet_printed": "Vytištěno",
|
||||
"worksheet_never": "Nikdy"
|
||||
"worksheet_never": "Nikdy",
|
||||
"card_all_caught_up": "Vše hotovo — nic nevyžaduje pozornost",
|
||||
"postpone": "Odložit",
|
||||
"postpone_date_prompt": "Na jaké datum odložit tento termín?",
|
||||
"postpone_date_label": "Nové datum",
|
||||
"postponed": "Odloženo",
|
||||
"postponed_to": "Odloženo na",
|
||||
"season_window_label": "Sezónní okno (měsíce)",
|
||||
"season_window_hint": "Termín jen ve vybraných měsících; data mimo sezónu se posunou na další aktivní měsíc. Žádné = celý rok.",
|
||||
"series_end_label": "Končí",
|
||||
"series_end_never": "Nikdy (opakuje se stále)",
|
||||
"series_end_after_count": "Po počtu opakování",
|
||||
"series_end_until": "K datu",
|
||||
"series_end_count_label": "Počet opakování",
|
||||
"series_end_until_label": "Datum konce"
|
||||
}
|
||||
|
||||
@@ -692,5 +692,19 @@
|
||||
"worksheet_manual_excerpt": "Uddrag af manualen",
|
||||
"worksheet_pages": "sider",
|
||||
"worksheet_printed": "Udskrevet",
|
||||
"worksheet_never": "Aldrig"
|
||||
"worksheet_never": "Aldrig",
|
||||
"card_all_caught_up": "Alt er klaret — intet kræver opmærksomhed",
|
||||
"postpone": "Udskyd",
|
||||
"postpone_date_prompt": "Udskyd denne forekomst til hvilken dato?",
|
||||
"postpone_date_label": "Ny forfaldsdato",
|
||||
"postponed": "Udskudt",
|
||||
"postponed_to": "Udskudt til",
|
||||
"season_window_label": "Sæsonvindue (måneder)",
|
||||
"season_window_hint": "Kun forfalden i de valgte måneder; datoer uden for sæsonen rykkes til næste aktive måned. Ingen = hele året.",
|
||||
"series_end_label": "Slutter",
|
||||
"series_end_never": "Aldrig (gentages uendeligt)",
|
||||
"series_end_after_count": "Efter et antal gange",
|
||||
"series_end_until": "På en dato",
|
||||
"series_end_count_label": "Antal gange",
|
||||
"series_end_until_label": "Slutdato"
|
||||
}
|
||||
|
||||
@@ -692,5 +692,19 @@
|
||||
"worksheet_manual_excerpt": "Handbuch-Auszug",
|
||||
"worksheet_pages": "Seiten",
|
||||
"worksheet_printed": "Gedruckt",
|
||||
"worksheet_never": "Nie"
|
||||
"worksheet_never": "Nie",
|
||||
"card_all_caught_up": "Alles erledigt — nichts braucht Aufmerksamkeit",
|
||||
"postpone": "Verschieben",
|
||||
"postpone_date_prompt": "Diesen Termin auf welches Datum verschieben?",
|
||||
"postpone_date_label": "Neues Fälligkeitsdatum",
|
||||
"postponed": "Verschoben",
|
||||
"postponed_to": "Verschoben auf",
|
||||
"season_window_label": "Saisonfenster (Monate)",
|
||||
"season_window_hint": "Nur in den gewählten Monaten fällig; Termine außerhalb rollen in den nächsten aktiven Monat. Keine = ganzjährig.",
|
||||
"series_end_label": "Endet",
|
||||
"series_end_never": "Nie (wiederholt unbegrenzt)",
|
||||
"series_end_after_count": "Nach Anzahl",
|
||||
"series_end_until": "An einem Datum",
|
||||
"series_end_count_label": "Anzahl",
|
||||
"series_end_until_label": "Enddatum"
|
||||
}
|
||||
|
||||
@@ -692,5 +692,19 @@
|
||||
"worksheet_manual_excerpt": "Manual excerpt",
|
||||
"worksheet_pages": "pages",
|
||||
"worksheet_printed": "Printed",
|
||||
"worksheet_never": "Never"
|
||||
"worksheet_never": "Never",
|
||||
"card_all_caught_up": "All caught up — nothing needs attention",
|
||||
"postpone": "Postpone",
|
||||
"postpone_date_prompt": "Postpone this occurrence to which date?",
|
||||
"postpone_date_label": "New due date",
|
||||
"postponed": "Postponed",
|
||||
"postponed_to": "Postponed to",
|
||||
"season_window_label": "Seasonal window (months)",
|
||||
"season_window_hint": "Only due in the selected months; off-season dates roll to the next active month. None = all year.",
|
||||
"series_end_label": "Ends",
|
||||
"series_end_never": "Never (repeats indefinitely)",
|
||||
"series_end_after_count": "After a number of times",
|
||||
"series_end_until": "On a date",
|
||||
"series_end_count_label": "Number of times",
|
||||
"series_end_until_label": "End date"
|
||||
}
|
||||
|
||||
@@ -692,5 +692,19 @@
|
||||
"worksheet_manual_excerpt": "Extracto del manual",
|
||||
"worksheet_pages": "páginas",
|
||||
"worksheet_printed": "Impreso",
|
||||
"worksheet_never": "Nunca"
|
||||
"worksheet_never": "Nunca",
|
||||
"card_all_caught_up": "Todo al día — nada requiere atención",
|
||||
"postpone": "Posponer",
|
||||
"postpone_date_prompt": "¿A qué fecha posponer esta vez?",
|
||||
"postpone_date_label": "Nueva fecha de vencimiento",
|
||||
"postponed": "Pospuesto",
|
||||
"postponed_to": "Pospuesto hasta",
|
||||
"season_window_label": "Ventana estacional (meses)",
|
||||
"season_window_hint": "Solo vence en los meses seleccionados; las fechas fuera de temporada pasan al siguiente mes activo. Ninguno = todo el año.",
|
||||
"series_end_label": "Termina",
|
||||
"series_end_never": "Nunca (se repite indefinidamente)",
|
||||
"series_end_after_count": "Tras un número de veces",
|
||||
"series_end_until": "En una fecha",
|
||||
"series_end_count_label": "Número de veces",
|
||||
"series_end_until_label": "Fecha de fin"
|
||||
}
|
||||
|
||||
@@ -692,5 +692,19 @@
|
||||
"worksheet_manual_excerpt": "Ote käyttöohjeesta",
|
||||
"worksheet_pages": "sivut",
|
||||
"worksheet_printed": "Tulostettu",
|
||||
"worksheet_never": "Ei koskaan"
|
||||
"worksheet_never": "Ei koskaan",
|
||||
"card_all_caught_up": "Kaikki kunnossa — mikään ei vaadi huomiota",
|
||||
"postpone": "Lykkää",
|
||||
"postpone_date_prompt": "Mihin päivään tämä kerta lykätään?",
|
||||
"postpone_date_label": "Uusi eräpäivä",
|
||||
"postponed": "Lykätty",
|
||||
"postponed_to": "Lykätty päivään",
|
||||
"season_window_label": "Kausi-ikkuna (kuukaudet)",
|
||||
"season_window_hint": "Erääntyy vain valittuina kuukausina; kauden ulkopuoliset päivät siirtyvät seuraavaan aktiiviseen kuukauteen. Ei mitään = koko vuosi.",
|
||||
"series_end_label": "Päättyy",
|
||||
"series_end_never": "Ei koskaan (toistuu loputtomasti)",
|
||||
"series_end_after_count": "Tietyn kertamäärän jälkeen",
|
||||
"series_end_until": "Päivämääränä",
|
||||
"series_end_count_label": "Kertojen määrä",
|
||||
"series_end_until_label": "Päättymispäivä"
|
||||
}
|
||||
|
||||
@@ -692,5 +692,19 @@
|
||||
"worksheet_manual_excerpt": "Extrait du manuel",
|
||||
"worksheet_pages": "pages",
|
||||
"worksheet_printed": "Imprimé",
|
||||
"worksheet_never": "Jamais"
|
||||
"worksheet_never": "Jamais",
|
||||
"card_all_caught_up": "Tout est à jour — rien ne requiert d'attention",
|
||||
"postpone": "Reporter",
|
||||
"postpone_date_prompt": "Reporter cette échéance à quelle date ?",
|
||||
"postpone_date_label": "Nouvelle date d’échéance",
|
||||
"postponed": "Reporté",
|
||||
"postponed_to": "Reporté au",
|
||||
"season_window_label": "Fenêtre saisonnière (mois)",
|
||||
"season_window_hint": "Dû uniquement les mois sélectionnés ; les dates hors saison passent au mois actif suivant. Aucun = toute l’année.",
|
||||
"series_end_label": "Se termine",
|
||||
"series_end_never": "Jamais (répète indéfiniment)",
|
||||
"series_end_after_count": "Après un nombre de fois",
|
||||
"series_end_until": "À une date",
|
||||
"series_end_count_label": "Nombre de fois",
|
||||
"series_end_until_label": "Date de fin"
|
||||
}
|
||||
|
||||
@@ -692,5 +692,19 @@
|
||||
"worksheet_manual_excerpt": "मैनुअल अंश",
|
||||
"worksheet_pages": "पृष्ठ",
|
||||
"worksheet_printed": "मुद्रित",
|
||||
"worksheet_never": "कभी नहीं"
|
||||
"worksheet_never": "कभी नहीं",
|
||||
"card_all_caught_up": "सब पूरा — किसी चीज़ पर ध्यान देने की ज़रूरत नहीं",
|
||||
"postpone": "टालें",
|
||||
"postpone_date_prompt": "इस बार को किस तारीख तक टालें?",
|
||||
"postpone_date_label": "नई नियत तिथि",
|
||||
"postponed": "टाल दिया गया",
|
||||
"postponed_to": "तक टाला गया",
|
||||
"season_window_label": "मौसमी अवधि (महीने)",
|
||||
"season_window_hint": "केवल चुने गए महीनों में देय; ऑफ-सीज़न तिथियाँ अगले सक्रिय महीने में चली जाती हैं। कोई नहीं = पूरे वर्ष।",
|
||||
"series_end_label": "समाप्त होता है",
|
||||
"series_end_never": "कभी नहीं (अनिश्चित रूप से दोहराता है)",
|
||||
"series_end_after_count": "कुछ बार के बाद",
|
||||
"series_end_until": "एक तिथि पर",
|
||||
"series_end_count_label": "कितनी बार",
|
||||
"series_end_until_label": "समाप्ति तिथि"
|
||||
}
|
||||
|
||||
@@ -692,5 +692,19 @@
|
||||
"worksheet_manual_excerpt": "Estratto del manuale",
|
||||
"worksheet_pages": "pagine",
|
||||
"worksheet_printed": "Stampato",
|
||||
"worksheet_never": "Mai"
|
||||
"worksheet_never": "Mai",
|
||||
"card_all_caught_up": "Tutto in ordine — niente richiede attenzione",
|
||||
"postpone": "Posticipa",
|
||||
"postpone_date_prompt": "A quale data posticipare questa scadenza?",
|
||||
"postpone_date_label": "Nuova data di scadenza",
|
||||
"postponed": "Posticipato",
|
||||
"postponed_to": "Posticipato al",
|
||||
"season_window_label": "Finestra stagionale (mesi)",
|
||||
"season_window_hint": "Scade solo nei mesi selezionati; le date fuori stagione passano al mese attivo successivo. Nessuno = tutto l’anno.",
|
||||
"series_end_label": "Termina",
|
||||
"series_end_never": "Mai (ripete all’infinito)",
|
||||
"series_end_after_count": "Dopo un numero di volte",
|
||||
"series_end_until": "In una data",
|
||||
"series_end_count_label": "Numero di volte",
|
||||
"series_end_until_label": "Data di fine"
|
||||
}
|
||||
|
||||
@@ -692,5 +692,19 @@
|
||||
"worksheet_manual_excerpt": "マニュアル抜粋",
|
||||
"worksheet_pages": "ページ",
|
||||
"worksheet_printed": "印刷日",
|
||||
"worksheet_never": "未実施"
|
||||
"worksheet_never": "未実施",
|
||||
"card_all_caught_up": "すべて完了 — 対応が必要なものはありません",
|
||||
"postpone": "延期",
|
||||
"postpone_date_prompt": "この回をどの日付に延期しますか?",
|
||||
"postpone_date_label": "新しい期日",
|
||||
"postponed": "延期済み",
|
||||
"postponed_to": "延期先",
|
||||
"season_window_label": "季節ウィンドウ(月)",
|
||||
"season_window_hint": "選択した月のみ期限;シーズン外の日付は次の有効な月へ繰り越し。なし=通年。",
|
||||
"series_end_label": "終了",
|
||||
"series_end_never": "なし(無期限に繰り返す)",
|
||||
"series_end_after_count": "回数の後",
|
||||
"series_end_until": "日付で",
|
||||
"series_end_count_label": "回数",
|
||||
"series_end_until_label": "終了日"
|
||||
}
|
||||
|
||||
@@ -692,5 +692,19 @@
|
||||
"worksheet_manual_excerpt": "Utdrag fra manualen",
|
||||
"worksheet_pages": "sider",
|
||||
"worksheet_printed": "Skrevet ut",
|
||||
"worksheet_never": "Aldri"
|
||||
"worksheet_never": "Aldri",
|
||||
"card_all_caught_up": "Alt er i orden — ingenting trenger oppmerksomhet",
|
||||
"postpone": "Utsett",
|
||||
"postpone_date_prompt": "Utsett denne forekomsten til hvilken dato?",
|
||||
"postpone_date_label": "Ny forfallsdato",
|
||||
"postponed": "Utsatt",
|
||||
"postponed_to": "Utsatt til",
|
||||
"season_window_label": "Sesongvindu (måneder)",
|
||||
"season_window_hint": "Forfaller kun i valgte måneder; datoer utenfor sesongen flyttes til neste aktive måned. Ingen = hele året.",
|
||||
"series_end_label": "Slutter",
|
||||
"series_end_never": "Aldri (gjentas uendelig)",
|
||||
"series_end_after_count": "Etter et antall ganger",
|
||||
"series_end_until": "På en dato",
|
||||
"series_end_count_label": "Antall ganger",
|
||||
"series_end_until_label": "Sluttdato"
|
||||
}
|
||||
|
||||
@@ -692,5 +692,19 @@
|
||||
"worksheet_manual_excerpt": "Handleidingfragment",
|
||||
"worksheet_pages": "pagina's",
|
||||
"worksheet_printed": "Afgedrukt",
|
||||
"worksheet_never": "Nooit"
|
||||
"worksheet_never": "Nooit",
|
||||
"card_all_caught_up": "Alles bijgewerkt — niets vraagt aandacht",
|
||||
"postpone": "Uitstellen",
|
||||
"postpone_date_prompt": "Deze keer uitstellen tot welke datum?",
|
||||
"postpone_date_label": "Nieuwe vervaldatum",
|
||||
"postponed": "Uitgesteld",
|
||||
"postponed_to": "Uitgesteld tot",
|
||||
"season_window_label": "Seizoensvenster (maanden)",
|
||||
"season_window_hint": "Alleen verschuldigd in de gekozen maanden; data buiten het seizoen schuiven naar de volgende actieve maand. Geen = hele jaar.",
|
||||
"series_end_label": "Eindigt",
|
||||
"series_end_never": "Nooit (herhaalt onbeperkt)",
|
||||
"series_end_after_count": "Na een aantal keer",
|
||||
"series_end_until": "Op een datum",
|
||||
"series_end_count_label": "Aantal keer",
|
||||
"series_end_until_label": "Einddatum"
|
||||
}
|
||||
|
||||
@@ -692,5 +692,19 @@
|
||||
"worksheet_manual_excerpt": "Fragment instrukcji",
|
||||
"worksheet_pages": "strony",
|
||||
"worksheet_printed": "Wydrukowano",
|
||||
"worksheet_never": "Nigdy"
|
||||
"worksheet_never": "Nigdy",
|
||||
"card_all_caught_up": "Wszystko zrobione — nic nie wymaga uwagi",
|
||||
"postpone": "Odłóż",
|
||||
"postpone_date_prompt": "Przełożyć ten termin na jaką datę?",
|
||||
"postpone_date_label": "Nowa data",
|
||||
"postponed": "Odłożono",
|
||||
"postponed_to": "Odłożono do",
|
||||
"season_window_label": "Okno sezonowe (miesiące)",
|
||||
"season_window_hint": "Termin tylko w wybranych miesiącach; daty poza sezonem przechodzą na następny aktywny miesiąc. Brak = cały rok.",
|
||||
"series_end_label": "Kończy się",
|
||||
"series_end_never": "Nigdy (powtarza bez końca)",
|
||||
"series_end_after_count": "Po liczbie razy",
|
||||
"series_end_until": "W dniu",
|
||||
"series_end_count_label": "Liczba razy",
|
||||
"series_end_until_label": "Data końcowa"
|
||||
}
|
||||
|
||||
@@ -692,5 +692,19 @@
|
||||
"worksheet_manual_excerpt": "Excerto do manual",
|
||||
"worksheet_pages": "páginas",
|
||||
"worksheet_printed": "Impresso",
|
||||
"worksheet_never": "Nunca"
|
||||
"worksheet_never": "Nunca",
|
||||
"card_all_caught_up": "Tudo em dia — nada requer atenção",
|
||||
"postpone": "Adiar",
|
||||
"postpone_date_prompt": "Adiar esta ocorrência para qual data?",
|
||||
"postpone_date_label": "Nova data de vencimento",
|
||||
"postponed": "Adiado",
|
||||
"postponed_to": "Adiado para",
|
||||
"season_window_label": "Janela sazonal (meses)",
|
||||
"season_window_hint": "Só vence nos meses selecionados; datas fora de época passam para o próximo mês ativo. Nenhum = todo o ano.",
|
||||
"series_end_label": "Termina",
|
||||
"series_end_never": "Nunca (repete indefinidamente)",
|
||||
"series_end_after_count": "Após um número de vezes",
|
||||
"series_end_until": "Numa data",
|
||||
"series_end_count_label": "Número de vezes",
|
||||
"series_end_until_label": "Data final"
|
||||
}
|
||||
|
||||
@@ -692,5 +692,19 @@
|
||||
"worksheet_manual_excerpt": "Выдержка из руководства",
|
||||
"worksheet_pages": "страницы",
|
||||
"worksheet_printed": "Напечатано",
|
||||
"worksheet_never": "Никогда"
|
||||
"worksheet_never": "Никогда",
|
||||
"card_all_caught_up": "Всё выполнено — ничего не требует внимания",
|
||||
"postpone": "Отложить",
|
||||
"postpone_date_prompt": "На какую дату отложить этот раз?",
|
||||
"postpone_date_label": "Новый срок",
|
||||
"postponed": "Отложено",
|
||||
"postponed_to": "Отложено до",
|
||||
"season_window_label": "Сезонное окно (месяцы)",
|
||||
"season_window_hint": "Срок только в выбранные месяцы; даты вне сезона переносятся на следующий активный месяц. Нет = весь год.",
|
||||
"series_end_label": "Заканчивается",
|
||||
"series_end_never": "Никогда (повторяется бесконечно)",
|
||||
"series_end_after_count": "После числа раз",
|
||||
"series_end_until": "В дату",
|
||||
"series_end_count_label": "Число раз",
|
||||
"series_end_until_label": "Дата окончания"
|
||||
}
|
||||
|
||||
@@ -692,5 +692,19 @@
|
||||
"worksheet_manual_excerpt": "Utdrag ur manualen",
|
||||
"worksheet_pages": "sidor",
|
||||
"worksheet_printed": "Utskriven",
|
||||
"worksheet_never": "Aldrig"
|
||||
"worksheet_never": "Aldrig",
|
||||
"card_all_caught_up": "Allt klart — inget behöver åtgärdas",
|
||||
"postpone": "Skjut upp",
|
||||
"postpone_date_prompt": "Skjut upp den här gången till vilket datum?",
|
||||
"postpone_date_label": "Nytt förfallodatum",
|
||||
"postponed": "Uppskjuten",
|
||||
"postponed_to": "Uppskjuten till",
|
||||
"season_window_label": "Säsongsfönster (månader)",
|
||||
"season_window_hint": "Förfaller endast valda månader; datum utanför säsongen flyttas till nästa aktiva månad. Inga = hela året.",
|
||||
"series_end_label": "Slutar",
|
||||
"series_end_never": "Aldrig (upprepas i oändlighet)",
|
||||
"series_end_after_count": "Efter ett antal gånger",
|
||||
"series_end_until": "På ett datum",
|
||||
"series_end_count_label": "Antal gånger",
|
||||
"series_end_until_label": "Slutdatum"
|
||||
}
|
||||
|
||||
@@ -692,5 +692,19 @@
|
||||
"worksheet_manual_excerpt": "Витяг з посібника",
|
||||
"worksheet_pages": "сторінки",
|
||||
"worksheet_printed": "Надруковано",
|
||||
"worksheet_never": "Ніколи"
|
||||
"worksheet_never": "Ніколи",
|
||||
"card_all_caught_up": "Усе виконано — ніщо не потребує уваги",
|
||||
"postpone": "Відкласти",
|
||||
"postpone_date_prompt": "На яку дату відкласти цей раз?",
|
||||
"postpone_date_label": "Новий термін",
|
||||
"postponed": "Відкладено",
|
||||
"postponed_to": "Відкладено до",
|
||||
"season_window_label": "Сезонне вікно (місяці)",
|
||||
"season_window_hint": "Термін лише у вибрані місяці; дати поза сезоном переносяться на наступний активний місяць. Немає = весь рік.",
|
||||
"series_end_label": "Завершується",
|
||||
"series_end_never": "Ніколи (повторюється безкінечно)",
|
||||
"series_end_after_count": "Після кількості разів",
|
||||
"series_end_until": "У дату",
|
||||
"series_end_count_label": "Кількість разів",
|
||||
"series_end_until_label": "Дата завершення"
|
||||
}
|
||||
|
||||
@@ -692,5 +692,19 @@
|
||||
"worksheet_manual_excerpt": "手册摘录",
|
||||
"worksheet_pages": "页",
|
||||
"worksheet_printed": "打印于",
|
||||
"worksheet_never": "从未"
|
||||
"worksheet_never": "从未",
|
||||
"card_all_caught_up": "全部完成——没有需要处理的事项",
|
||||
"postpone": "推迟",
|
||||
"postpone_date_prompt": "将此次推迟到哪一天?",
|
||||
"postpone_date_label": "新的到期日",
|
||||
"postponed": "已推迟",
|
||||
"postponed_to": "推迟至",
|
||||
"season_window_label": "季节窗口(月份)",
|
||||
"season_window_hint": "仅在所选月份到期;非当季日期顺延至下一个活动月份。无 = 全年。",
|
||||
"series_end_label": "结束",
|
||||
"series_end_never": "从不(无限重复)",
|
||||
"series_end_after_count": "达到次数后",
|
||||
"series_end_until": "在某日期",
|
||||
"series_end_count_label": "次数",
|
||||
"series_end_until_label": "结束日期"
|
||||
}
|
||||
|
||||
@@ -249,10 +249,16 @@ export class MaintenanceSupporterCard extends LitElement {
|
||||
</div>
|
||||
</div>
|
||||
${tasks.length === 0
|
||||
? html`<div class="empty-card">
|
||||
<div>${t("card_no_tasks_title", L)}</div>
|
||||
<a class="empty-link" href="/maintenance-supporter">${t("card_no_tasks_cta", L)}</a>
|
||||
</div>`
|
||||
? this._objects.some((o) => o.tasks.length > 0)
|
||||
? html`<div class="empty-card">
|
||||
<!-- (#86) tasks exist but none match the filter (default:
|
||||
actionable-only) — "all caught up", NOT "no tasks yet". -->
|
||||
<div class="all-caught-up">✓ ${t("card_all_caught_up", L)}</div>
|
||||
</div>`
|
||||
: html`<div class="empty-card">
|
||||
<div>${t("card_no_tasks_title", L)}</div>
|
||||
<a class="empty-link" href="/maintenance-supporter">${t("card_no_tasks_cta", L)}</a>
|
||||
</div>`
|
||||
: html`
|
||||
<div class="task-list ${compact ? "compact" : ""}">
|
||||
${tasks.map(
|
||||
@@ -262,7 +268,16 @@ export class MaintenanceSupporterCard extends LitElement {
|
||||
title="${t("open_task", L) || "Open task"}">
|
||||
<div class="status-dot" style="background: ${STATUS_COLORS[task.status] || "#ccc"}"></div>
|
||||
<div class="task-info">
|
||||
<div class="task-name">${task.name}</div>
|
||||
<div class="task-name">
|
||||
${task.name}
|
||||
${(task as any).due_override
|
||||
? html`<ha-icon
|
||||
class="postponed-icon"
|
||||
icon="mdi:calendar-clock"
|
||||
title="${t("postponed", L) || "Postponed"}"
|
||||
></ha-icon>`
|
||||
: nothing}
|
||||
</div>
|
||||
${!compact ? html`<div class="task-meta">${object_name} · ${t(task.type, L)}</div>` : nothing}
|
||||
</div>
|
||||
<div class="task-due">
|
||||
@@ -366,6 +381,7 @@ export class MaintenanceSupporterCard extends LitElement {
|
||||
font-size: 13px;
|
||||
}
|
||||
.empty-link:hover { text-decoration: underline; }
|
||||
.all-caught-up { color: var(--success-color, #4caf50); font-weight: 500; }
|
||||
.task-list { padding: 0 16px 16px; }
|
||||
|
||||
.task-item {
|
||||
@@ -387,6 +403,7 @@ export class MaintenanceSupporterCard extends LitElement {
|
||||
.status-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||
.task-info { flex: 1; min-width: 0; }
|
||||
.task-name { font-size: 14px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.postponed-icon { --mdc-icon-size: 14px; color: var(--secondary-text-color); vertical-align: text-bottom; margin-inline-start: 4px; }
|
||||
.task-meta { font-size: 12px; color: var(--secondary-text-color); }
|
||||
|
||||
.task-due { font-size: 13px; color: var(--secondary-text-color); min-width: 40px; text-align: right; }
|
||||
|
||||
+24
-8
@@ -200,15 +200,22 @@ const SMALL_SCREEN_CONDITION: ViewColumnsCondition = {
|
||||
// truth shared with the panel KPI chips and the statistics WS endpoint — so the
|
||||
// numbers can't drift. Rendered live via a markdown template instead of being
|
||||
// counted client-side at generation time (which froze them until reload).
|
||||
function kpiMarkdownCard(): CardConfig {
|
||||
// (#86) Entity ids come registry-resolved from the statistics WS: the
|
||||
// documented sensor.maintenance_supporter_<key> ids are NOT guaranteed —
|
||||
// a user rename, a _2 collision suffix, or a pre-pinning install that
|
||||
// registered localized ids all made the hardcoded templates read "unknown".
|
||||
type SummaryEntityIds = Partial<Record<"overdue" | "triggered" | "due_soon" | "ok", string | null>>;
|
||||
|
||||
function kpiMarkdownCard(ids: SummaryEntityIds = {}): CardConfig {
|
||||
const eid = (key: keyof SummaryEntityIds) => ids[key] || `sensor.maintenance_supporter_${key}`;
|
||||
return {
|
||||
type: "markdown",
|
||||
text_only: true,
|
||||
content: [
|
||||
"🔴 **{{ states('sensor.maintenance_supporter_overdue') }}** overdue",
|
||||
"⚡ **{{ states('sensor.maintenance_supporter_triggered') }}** triggered",
|
||||
"🟡 **{{ states('sensor.maintenance_supporter_due_soon') }}** due soon",
|
||||
"🟢 **{{ states('sensor.maintenance_supporter_ok') }}** ok",
|
||||
`🔴 **{{ states('${eid("overdue")}') }}** overdue`,
|
||||
`⚡ **{{ states('${eid("triggered")}') }}** triggered`,
|
||||
`🟡 **{{ states('${eid("due_soon")}') }}** due soon`,
|
||||
`🟢 **{{ states('${eid("ok")}') }}** ok`,
|
||||
].join(" · "),
|
||||
};
|
||||
}
|
||||
@@ -263,8 +270,8 @@ function emptyStateView(): ViewConfig {
|
||||
};
|
||||
}
|
||||
|
||||
function overviewView(): ViewConfig {
|
||||
const kpiCard = kpiMarkdownCard();
|
||||
function overviewView(summaryIds: SummaryEntityIds = {}): ViewConfig {
|
||||
const kpiCard = kpiMarkdownCard(summaryIds);
|
||||
return {
|
||||
title: "Overview",
|
||||
icon: "mdi:wrench-clock",
|
||||
@@ -614,10 +621,19 @@ export class MaintenanceDashboardStrategy extends HTMLElement {
|
||||
calendar: () => viewsByCalendar(objects),
|
||||
};
|
||||
|
||||
// Registry-resolved summary-sensor ids (#86); tolerate older backends.
|
||||
let summaryIds: SummaryEntityIds = {};
|
||||
try {
|
||||
const stats = await hass.connection.sendMessagePromise<{ summary_entity_ids?: SummaryEntityIds }>({
|
||||
type: "maintenance_supporter/statistics",
|
||||
});
|
||||
summaryIds = stats.summary_entity_ids || {};
|
||||
} catch { /* fall back to the documented default ids */ }
|
||||
|
||||
return {
|
||||
title: "Maintenance",
|
||||
views: [
|
||||
overviewView(),
|
||||
overviewView(summaryIds),
|
||||
...(viewBuilders[groupBy] ?? viewBuilders.area)(),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -57,7 +57,11 @@ import { type SparklineContext } from "./renderers/sparkline";
|
||||
import { buildCalendarBuckets, isoDateLocal, type CalendarEvent } from "./helpers/calendar-bucket";
|
||||
import { renderTriggerProgress, renderMiniSparkline } from "./renderers/progress";
|
||||
import { type HistoryContext } from "./renderers/history";
|
||||
import { renderTaskDetail, renderUserBadge, type TaskDetailContext } from "./renderers/task-detail";
|
||||
import { renderUserBadge, type TaskDetailContext } from "./renderers/task-detail";
|
||||
// The task-detail sub-view as a web component (light-DOM, panel styles apply).
|
||||
// Side-effect import: type-only imports get tree-shaken and the element
|
||||
// would never register.
|
||||
import "./components/task-detail-view";
|
||||
import { computeWindow, VIRTUAL_MIN_ROWS } from "./helpers/virtual-window";
|
||||
|
||||
type View = "overview" | "object" | "task" | "all_objects";
|
||||
@@ -1392,6 +1396,38 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
this._resetTask(entryId, taskId, result.value || undefined);
|
||||
}
|
||||
|
||||
private async _postponeTask(entryId: string, taskId: string, until: string): Promise<void> {
|
||||
this._actionLoading = true;
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/task/postpone",
|
||||
entry_id: entryId,
|
||||
task_id: taskId,
|
||||
until,
|
||||
});
|
||||
this._showToast(t("postponed", this._lang));
|
||||
await this._loadData();
|
||||
} catch {
|
||||
this._showToast(t("action_error", this._lang));
|
||||
} finally {
|
||||
this._actionLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async _promptPostponeTask(entryId: string, taskId: string): Promise<void> {
|
||||
const dlg = this.shadowRoot!.querySelector<MaintenanceConfirmDialog>("maintenance-confirm-dialog");
|
||||
if (!dlg) return;
|
||||
const result = await dlg.prompt({
|
||||
title: t("postpone", this._lang),
|
||||
message: t("postpone_date_prompt", this._lang),
|
||||
confirmText: t("postpone", this._lang),
|
||||
inputLabel: t("postpone_date_label", this._lang),
|
||||
inputType: "date",
|
||||
});
|
||||
if (!result.confirmed || !result.value) return;
|
||||
this._postponeTask(entryId, taskId, result.value);
|
||||
}
|
||||
|
||||
private async _snoozeTask(entryId: string, taskId: string): Promise<void> {
|
||||
this._actionLoading = true;
|
||||
try {
|
||||
@@ -2855,6 +2891,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
openQr: (taskName) => this._openQrForTask(entryId, taskId, obj?.object.name || "", taskName),
|
||||
duplicateTask: () => this._duplicateTask(entryId, taskId),
|
||||
promptReset: () => this._promptResetTask(entryId, taskId),
|
||||
promptPostpone: () => this._promptPostponeTask(entryId, taskId),
|
||||
snoozeTask: () => this._snoozeTask(entryId, taskId),
|
||||
printWorksheet: () => this._printTaskWorksheet(entryId, taskId),
|
||||
deleteTask: () => this._deleteTask(entryId, taskId),
|
||||
@@ -2869,7 +2906,10 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
if (!this._selectedEntryId || !this._selectedTaskId) return nothing;
|
||||
const task = this._getTask(this._selectedEntryId, this._selectedTaskId);
|
||||
if (!task) return html`<p>Task not found.</p>`;
|
||||
return renderTaskDetail(task, this._taskDetailCtx());
|
||||
return html`<maintenance-task-detail-view
|
||||
.task=${task}
|
||||
.ctx=${this._taskDetailCtx()}
|
||||
></maintenance-task-detail-view>`;
|
||||
}
|
||||
|
||||
/** v2.2.0: open the in-place history-edit dialog for the given entry.
|
||||
|
||||
@@ -304,6 +304,10 @@ export const panelStyles = css`
|
||||
--mdc-icon-size: 26px;
|
||||
}
|
||||
|
||||
/* 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; }
|
||||
|
||||
.detail-section { padding: 16px 0; }
|
||||
|
||||
.detail-header {
|
||||
@@ -589,9 +593,29 @@ export const panelStyles = css`
|
||||
border-radius: 12px;
|
||||
padding: 2px;
|
||||
}
|
||||
/* Inside .cell-badges the parent's gap does the spacing — the badges' own
|
||||
margin-left (meant for inline use, e.g. the detail header) would double
|
||||
it and push the priority chevron out of line with the other badges. */
|
||||
.cell-badges .nfc-badge,
|
||||
.cell-badges .priority-badge {
|
||||
margin-left: 0;
|
||||
}
|
||||
.priority-badge ha-icon {
|
||||
--mdc-icon-size: 16px;
|
||||
}
|
||||
.postponed-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 2px 8px;
|
||||
margin-left: 8px;
|
||||
background: var(--secondary-background-color, #e8e8e8);
|
||||
color: var(--secondary-text-color);
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.postponed-badge ha-icon { --mdc-icon-size: 13px; }
|
||||
.priority-high {
|
||||
color: var(--error-color, #db4437);
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ export interface TaskDetailContext {
|
||||
openQr: (taskName: string) => void;
|
||||
duplicateTask: () => void;
|
||||
promptReset: () => void;
|
||||
promptPostpone: () => void;
|
||||
snoozeTask: () => void;
|
||||
/** v2.21: open the printable one-pager for this task. */
|
||||
printWorksheet: () => void;
|
||||
@@ -104,6 +105,9 @@ function renderTaskHeader(task: MaintenanceTask, ctx: TaskDetailContext) {
|
||||
<span class="breadcrumb-separator">·</span>
|
||||
<span class="object-name-breadcrumb" @click=${() => ctx.showObject()}>${ctx.objectName}</span>
|
||||
<span class="status-chip ${statusClass}">${statusText}</span>
|
||||
${task.due_override ? html`<span class="postponed-badge" title="${t("postponed_to", L)}">
|
||||
<ha-icon icon="mdi:calendar-arrow-right"></ha-icon>${formatDate(task.due_override, L)}
|
||||
</span>` : nothing}
|
||||
${renderUserBadge(task, ctx.getUserName)}
|
||||
${task.nfc_tag_id
|
||||
? html`<span class="nfc-badge" title="${t("nfc_tag_id", L)}: ${task.nfc_tag_id}"><ha-icon icon="mdi:nfc-variant"></ha-icon> NFC</span>`
|
||||
@@ -131,6 +135,7 @@ function renderTaskHeader(task: MaintenanceTask, ctx: TaskDetailContext) {
|
||||
<div class="popup-menu-item" @click=${() => { ctx.closeMoreMenu(); ctx.openEdit(task); }}>${t("edit", L)}</div>
|
||||
<div class="popup-menu-item" @click=${() => ctx.duplicateTask()}>${t("duplicate", L)}</div>
|
||||
<div class="popup-menu-item" @click=${() => { ctx.closeMoreMenu(); ctx.promptReset(); }}>${t("reset", L)}</div>
|
||||
<div class="popup-menu-item" @click=${() => { ctx.closeMoreMenu(); ctx.promptPostpone(); }}>${t("postpone", L)}…</div>
|
||||
<div class="popup-menu-item" @click=${() => { ctx.closeMoreMenu(); ctx.snoozeTask(); }}>${t("snooze", L)}</div>
|
||||
<div class="popup-menu-item" @click=${() => { ctx.closeMoreMenu(); ctx.printWorksheet(); }}>${t("worksheet", L)}</div>
|
||||
<div class="popup-menu-divider"></div>
|
||||
|
||||
@@ -740,11 +740,14 @@ export const sharedStyles = css`
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
/* Trigger progress bar (overview rows) */
|
||||
/* Trigger progress bar (overview rows). width:100% — the due-cell doesn't
|
||||
stretch its children (align-items: flex-end), so without it the bar
|
||||
shrinks to its label and reads shorter than the days-bar in other rows. */
|
||||
.trigger-progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
min-width: 90px;
|
||||
}
|
||||
|
||||
|
||||
@@ -133,6 +133,10 @@ export interface TaskSchedule {
|
||||
business?: boolean;
|
||||
/** (#83) shift the computed occurrence by ±N days (clamped ±15). */
|
||||
offset?: number;
|
||||
/** Seasonal active window — months (1..12) the task may be due in. */
|
||||
season_months?: number[];
|
||||
/** Finite-series end condition: after N completions and/or past a date. */
|
||||
ends?: { count?: number; until?: string };
|
||||
}
|
||||
|
||||
export interface MaintenanceTask {
|
||||
@@ -182,6 +186,8 @@ export interface MaintenanceTask {
|
||||
archived_reason?: string | null;
|
||||
days_until_due?: number | null;
|
||||
next_due?: string | null;
|
||||
/** Per-occurrence postpone: the ISO date the current cycle was deferred to. */
|
||||
due_override?: string | null;
|
||||
trigger_active: boolean;
|
||||
trigger_current_value?: number | null;
|
||||
trigger_current_delta?: number | null;
|
||||
|
||||
Reference in New Issue
Block a user