108 files

This commit is contained in:
Home Assistant Version Control
2026-08-13 23:38:20 +00:00
parent d851c2a3a5
commit 4e0539a155
108 changed files with 1977 additions and 9644 deletions
@@ -23,6 +23,19 @@ export interface HistoryEntryDraft {
cost: number | null;
duration: number | null;
completed_by: string | null;
// #130: the entry's recorded part consumption ({part_id, name, quantity,
// entry_id? for pooled parts}); absent/empty = nothing consumed.
used_parts?: Array<{ part_id: string; name?: string; quantity: number; entry_id?: string }> | null;
}
/** One selectable part option in the edit dialog — the object's own parts
* plus pooled parts this task links to, fetched via parts/overview. */
interface PartOption {
part_id: string;
name: string;
entry_id: string; // owning object
foreign: boolean; // pooled (#111) — carried into the saved link
object_name: string | null;
}
export class MaintenanceHistoryEditDialog extends LitElement {
@@ -40,6 +53,12 @@ export class MaintenanceHistoryEditDialog extends LitElement {
return this.hass?.language || "en";
}
// #130: selectable parts + the edited selection (part key -> quantity;
// 0/absent = not consumed). Key = `${entry_id}:${part_id}` (pool-safe).
@state() private _partOptions: PartOption[] | null = null;
@state() private _partQty: Record<string, number> = {};
private _partQtyOriginal = "";
/** Open the dialog with the given history-entry data. The caller must
* pass `original_timestamp` (the entry's current timestamp before edit)
* so the backend can find the entry. */
@@ -48,6 +67,71 @@ export class MaintenanceHistoryEditDialog extends LitElement {
this._originalSnapshot = { ...draft };
this._error = "";
this._open = true;
this._partOptions = null;
this._partQty = {};
this._partQtyOriginal = "";
void this._loadPartOptions();
}
/** The object's own parts + pooled parts this task draws on — from the
* instance-wide overview so pooled owners resolve without extra calls. */
private async _loadPartOptions(): Promise<void> {
const draft = this._draft;
if (!draft) return;
try {
const result = await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/parts/overview",
}) as {
parts: Array<{
part_id: string; name: string; entry_id: string; object_name: string | null;
consumers: Array<{ entry_id: string; task_id: string }>;
}>;
};
const options: PartOption[] = [];
for (const row of result.parts || []) {
const own = row.entry_id === draft.entry_id;
const linked = row.consumers.some((c) => c.entry_id === draft.entry_id && c.task_id === draft.task_id);
if (!own && !linked) continue;
options.push({
part_id: row.part_id,
name: row.name,
entry_id: row.entry_id,
foreign: !own,
object_name: row.object_name,
});
}
// Recorded parts whose catalog entry vanished stay selectable so a
// correction can still zero them out.
for (const link of draft.used_parts || []) {
const owner = link.entry_id || draft.entry_id;
if (!options.some((o) => o.part_id === link.part_id && o.entry_id === owner)) {
options.push({
part_id: link.part_id,
name: link.name || link.part_id,
entry_id: owner,
foreign: owner !== draft.entry_id,
object_name: null,
});
}
}
const qty: Record<string, number> = {};
for (const link of draft.used_parts || []) {
qty[`${link.entry_id || draft.entry_id}:${link.part_id}`] = link.quantity ?? 1;
}
this._partOptions = options;
this._partQty = qty;
this._partQtyOriginal = this._partSelectionKey();
} catch {
this._partOptions = []; // parts UI unavailable — the rest still edits
}
}
private _partSelectionKey(): string {
return JSON.stringify(
Object.entries(this._partQty)
.filter(([, q]) => q > 0)
.sort(([a], [b]) => a.localeCompare(b)),
);
}
public close(): void {
@@ -92,6 +176,17 @@ export class MaintenanceHistoryEditDialog extends LitElement {
if (this._draft.completed_by !== this._originalSnapshot.completed_by) {
patch.completed_by = this._draft.completed_by;
}
// #130: send the parts selection only when it actually changed — the
// backend reconciles stock by the delta, so a no-op must stay silent.
if (this._partOptions !== null && this._partSelectionKey() !== this._partQtyOriginal) {
patch.used_parts = (this._partOptions || [])
.filter((o) => (this._partQty[`${o.entry_id}:${o.part_id}`] || 0) > 0)
.map((o) => ({
part_id: o.part_id,
quantity: this._partQty[`${o.entry_id}:${o.part_id}`],
...(o.foreign ? { entry_id: o.entry_id } : {}),
}));
}
// Nothing changed → close without WS call
const changedKeys = Object.keys(patch).filter(
(k) => !["type", "entry_id", "task_id", "original_timestamp"].includes(k),
@@ -173,6 +268,33 @@ export class MaintenanceHistoryEditDialog extends LitElement {
}} />
</label>
</div>
${this._partOptions && this._partOptions.length > 0 ? html`
<div class="parts-block">
<span class="parts-title">${t("complete_parts_used", L)}</span>
${this._partOptions.map((o) => {
const key = `${o.entry_id}:${o.part_id}`;
const qty = this._partQty[key] || 0;
return html`
<label class="part-row-edit">
<input type="checkbox" .checked=${qty > 0}
@change=${(e: Event) => {
const on = (e.target as HTMLInputElement).checked;
this._partQty = { ...this._partQty, [key]: on ? 1 : 0 };
}} />
<span class="part-label">${o.name}${o.foreign && o.object_name ? ` (${o.object_name})` : ""}</span>
${qty > 0 ? html`
<input class="part-qty" type="number" min="0.01" max="999" step="0.01"
.value=${String(qty)}
@input=${(e: Event) => {
const v = parseFloat((e.target as HTMLInputElement).value);
if (!isNaN(v) && v > 0) this._partQty = { ...this._partQty, [key]: v };
}} />
` : nothing}
</label>
`;
})}
</div>
` : nothing}
${this._error ? html`<div class="error">${this._error}</div>` : nothing}
<div class="actions">
<button class="cancel" @click=${this.close} ?disabled=${this._saving}>
@@ -248,6 +370,20 @@ export class MaintenanceHistoryEditDialog extends LitElement {
background: rgba(211,47,47,0.1);
border-radius: 6px;
}
/* #130: parts on the entry */
.parts-block {
display: flex; flex-direction: column; gap: 6px;
border: 1px solid var(--divider-color, #444);
border-radius: 6px; padding: 8px;
}
.parts-title { color: var(--secondary-text-color); font-size: 13px; }
.part-row-edit {
display: flex; flex-direction: row; align-items: center; gap: 8px;
font-size: 14px;
}
.part-row-edit input[type="checkbox"] { width: auto; }
.part-label { flex: 1; color: var(--primary-text-color); }
.part-qty { width: 76px; }
`;
}
@@ -320,6 +320,7 @@ export class MaintenanceTaskQuickActionsDialog extends LitElement {
cost: entry.cost ?? null,
duration: entry.duration ?? null,
completed_by: entry.completed_by ?? null,
used_parts: entry.used_parts ?? null,
});
});
}
@@ -177,6 +177,7 @@
"sort_type": "Typ",
"sort_task_name": "Název úkolu",
"all_objects": "Všechny objekty",
"all_parts": "Všechny díly",
"tasks_lower": "úkolů",
"no_tasks_yet": "Zatím žádné úkoly",
"add_first_task": "Přidat první úkol",
@@ -736,6 +737,7 @@
"part_restock_quantity": "Množství doplnění",
"part_auto_buy": "Úkol nákupu při nízké zásobě",
"part_restock": "Upravit zásobu",
"parts_used_by": "Používá",
"restock_quantity_label": "Zakoupené množství",
"consumes_parts_label": "Spotřebovává díly",
"shared_parts_other_objects": "Díly z jiných objektů",
@@ -800,7 +802,7 @@
"setups_baseline_hint": "stav při posledním servisu (volitelné)",
"baseline_start_help_edit": "Ponechte prázdné pro zachování stávajícího počítání. Zadaná hodnota počítání znovu ukotví (např. stav při posledním servisu).",
"baseline_current_effective": "Aktuálně platná počáteční hodnota: {value}",
"runtime_on_states": "Aktivní stavy (oddělené čárkami)",
"runtime_on_states": "Aktivní stavy",
"runtime_on_states_help": "Stavy počítané jako doba běhu — výchozí: on. Např. mowing, cleaning, printing. Při zvoleném atributu se porovnávají jeho hodnoty.",
"setups_target_new": "Vytvořit nový: {name}",
"schedule_preview_title": "Nejbližší termíny",
@@ -178,6 +178,7 @@
"sort_type": "Type",
"sort_task_name": "Opgavenavn",
"all_objects": "Alle objekter",
"all_parts": "Alle dele",
"tasks_lower": "opgaver",
"no_tasks_yet": "Ingen opgaver endnu",
"add_first_task": "Tilføj første opgave",
@@ -736,6 +737,7 @@
"part_restock_quantity": "Genopfyldningsmængde",
"part_auto_buy": "Købsopgave ved lav beholdning",
"part_restock": "Justér lager",
"parts_used_by": "Bruges af",
"restock_quantity_label": "Købt mængde",
"consumes_parts_label": "Forbruger dele",
"shared_parts_other_objects": "Dele fra andre objekter",
@@ -800,7 +802,7 @@
"setups_baseline_hint": "aflæsning ved sidste service (valgfrit)",
"baseline_start_help_edit": "Lad feltet stå tomt for at beholde den eksisterende tælling. En indtastet værdi forankrer tællingen på ny (f.eks. aflæsningen ved sidste service).",
"baseline_current_effective": "Aktuelt gældende startværdi: {value}",
"runtime_on_states": "Aktive tilstande (kommaseparerede)",
"runtime_on_states": "Aktive tilstande",
"runtime_on_states_help": "Tilstande der tæller som driftstid — standard: on. F.eks. mowing, cleaning, printing. Med en valgt attribut sammenlignes dens værdier i stedet.",
"setups_target_new": "Opret ny: {name}",
"schedule_preview_title": "Kommende datoer",
@@ -178,6 +178,7 @@
"sort_type": "Typ",
"sort_task_name": "Aufgaben-Name",
"all_objects": "Alle Objekte",
"all_parts": "Alle Teile",
"tasks_lower": "Aufgaben",
"no_tasks_yet": "Noch keine Aufgaben",
"add_first_task": "Erste Aufgabe hinzufügen",
@@ -736,6 +737,7 @@
"part_restock_quantity": "Auffüllmenge",
"part_auto_buy": "Kauf-Task bei Niedrigbestand",
"part_restock": "Bestand anpassen",
"parts_used_by": "Verwendet von",
"restock_quantity_label": "Gekaufte Menge",
"consumes_parts_label": "Verbraucht Teile",
"shared_parts_other_objects": "Teile anderer Objekte",
@@ -800,7 +802,7 @@
"setups_baseline_hint": "Stand beim letzten Service (optional)",
"baseline_start_help_edit": "Leer lassen, um die bestehende Zählung zu behalten. Ein eingetragener Wert verankert die Zählung neu (z. B. der Stand beim letzten Service).",
"baseline_current_effective": "Aktuell wirksamer Startwert: {value}",
"runtime_on_states": "Aktive Zustände (kommagetrennt)",
"runtime_on_states": "Aktive Zustände",
"runtime_on_states_help": "Zustände, die als Laufzeit zählen — Standard: on. Z. B. mowing, cleaning, printing. Bei gewähltem Attribut werden dessen Werte verglichen.",
"setups_target_new": "Neu anlegen: {name}",
"schedule_preview_title": "Nächste Termine",
@@ -178,6 +178,7 @@
"sort_type": "Type",
"sort_task_name": "Task name",
"all_objects": "All objects",
"all_parts": "All parts",
"tasks_lower": "tasks",
"no_tasks_yet": "No tasks yet",
"add_first_task": "Add first task",
@@ -742,6 +743,7 @@
"part_restock_quantity": "Restock quantity",
"part_auto_buy": "Auto-create buy task when low",
"part_restock": "Adjust stock",
"parts_used_by": "Used by",
"restock_quantity_label": "Quantity bought",
"consumes_parts_label": "Consumes parts",
"shared_parts_other_objects": "Parts from other objects",
@@ -800,7 +802,7 @@
"setups_baseline_hint": "reading at last service (optional)",
"baseline_start_help_edit": "Leave empty to keep the existing counting. Entering a value re-anchors the counting (e.g. the reading at the last service).",
"baseline_current_effective": "Currently effective start value: {value}",
"runtime_on_states": "Active states (comma-separated)",
"runtime_on_states": "Active states",
"runtime_on_states_help": "States that count as running — default: on. E.g. mowing, cleaning, printing. With an attribute selected, its values are matched instead.",
"setups_target_new": "Create new: {name}",
"schedule_preview_title": "Next dates",
@@ -177,6 +177,7 @@
"sort_type": "Tipo",
"sort_task_name": "Nombre de la tarea",
"all_objects": "Todos los objetos",
"all_parts": "Todas las piezas",
"tasks_lower": "tareas",
"no_tasks_yet": "Aún no hay tareas",
"add_first_task": "Agregar primera tarea",
@@ -736,6 +737,7 @@
"part_restock_quantity": "Cantidad de reposición",
"part_auto_buy": "Crear tarea de compra si bajo",
"part_restock": "Ajustar existencias",
"parts_used_by": "Usado por",
"restock_quantity_label": "Cantidad comprada",
"consumes_parts_label": "Consume piezas",
"shared_parts_other_objects": "Piezas de otros objetos",
@@ -800,7 +802,7 @@
"setups_baseline_hint": "lectura en el último mantenimiento (opcional)",
"baseline_start_help_edit": "Déjalo vacío para mantener el conteo actual. Un valor introducido reancla el conteo (p. ej., la lectura del último mantenimiento).",
"baseline_current_effective": "Valor inicial vigente: {value}",
"runtime_on_states": "Estados activos (separados por comas)",
"runtime_on_states": "Estados activos",
"runtime_on_states_help": "Estados que cuentan como en funcionamiento — predeterminado: on. P. ej. mowing, cleaning, printing. Con un atributo seleccionado se comparan sus valores.",
"setups_target_new": "Crear nuevo: {name}",
"schedule_preview_title": "Próximas fechas",
@@ -178,6 +178,7 @@
"sort_type": "Tyyppi",
"sort_task_name": "Tehtävän nimi",
"all_objects": "Kaikki kohteet",
"all_parts": "Kaikki osat",
"tasks_lower": "tehtävää",
"no_tasks_yet": "Ei vielä tehtäviä",
"add_first_task": "Lisää ensimmäinen tehtävä",
@@ -736,6 +737,7 @@
"part_restock_quantity": "Täydennysmäärä",
"part_auto_buy": "Ostotehtävä kun vähissä",
"part_restock": "Muuta varastoa",
"parts_used_by": "Käyttäjät",
"restock_quantity_label": "Ostettu määrä",
"consumes_parts_label": "Kuluttaa osia",
"shared_parts_other_objects": "Muiden kohteiden osat",
@@ -800,7 +802,7 @@
"setups_baseline_hint": "lukema viime huollossa (valinnainen)",
"baseline_start_help_edit": "Jätä tyhjäksi säilyttääksesi nykyisen laskennan. Syötetty arvo ankkuroi laskennan uudelleen (esim. viimeisimmän huollon lukema).",
"baseline_current_effective": "Nyt voimassa oleva aloitusarvo: {value}",
"runtime_on_states": "Aktiiviset tilat (pilkuin eroteltuina)",
"runtime_on_states": "Aktiiviset tilat",
"runtime_on_states_help": "Tilat, jotka lasketaan käyntiajaksi — oletus: on. Esim. mowing, cleaning, printing. Jos attribuutti on valittu, verrataan sen arvoja.",
"setups_target_new": "Luo uusi: {name}",
"schedule_preview_title": "Seuraavat päivämäärät",
@@ -177,6 +177,7 @@
"sort_type": "Type",
"sort_task_name": "Nom de la tâche",
"all_objects": "Tous les objets",
"all_parts": "Toutes les pièces",
"tasks_lower": "tâches",
"no_tasks_yet": "Pas encore de tâches",
"add_first_task": "Ajouter la première tâche",
@@ -736,6 +737,7 @@
"part_restock_quantity": "Quantité de réappro",
"part_auto_buy": "Tâche d'achat auto si bas",
"part_restock": "Ajuster le stock",
"parts_used_by": "Utilisé par",
"restock_quantity_label": "Quantité achetée",
"consumes_parts_label": "Consomme des pièces",
"shared_parts_other_objects": "Pièces d'autres objets",
@@ -800,7 +802,7 @@
"setups_baseline_hint": "relevé au dernier entretien (facultatif)",
"baseline_start_help_edit": "Laissez vide pour conserver le comptage existant. Une valeur saisie réancre le comptage (p. ex. le relevé du dernier entretien).",
"baseline_current_effective": "Valeur de départ actuellement en vigueur : {value}",
"runtime_on_states": "États actifs (séparés par des virgules)",
"runtime_on_states": "États actifs",
"runtime_on_states_help": "États comptés comme en fonctionnement — par défaut : on. P. ex. mowing, cleaning, printing. Si un attribut est sélectionné, ce sont ses valeurs qui sont comparées.",
"setups_target_new": "Créer : {name}",
"schedule_preview_title": "Prochaines échéances",
@@ -178,6 +178,7 @@
"sort_type": "प्रकार",
"sort_task_name": "कार्य का नाम",
"all_objects": "सभी वस्तुएँ",
"all_parts": "सभी पुर्ज़े",
"tasks_lower": "कार्य",
"no_tasks_yet": "अभी कोई कार्य नहीं",
"add_first_task": "पहला कार्य जोड़ें",
@@ -736,6 +737,7 @@
"part_restock_quantity": "पुनःपूर्ति मात्रा",
"part_auto_buy": "कम होने पर खरीद कार्य",
"part_restock": "स्टॉक समायोजित करें",
"parts_used_by": "द्वारा उपयोग",
"restock_quantity_label": "खरीदी गई मात्रा",
"consumes_parts_label": "पुर्ज़े खपत",
"shared_parts_other_objects": "अन्य वस्तुओं के पुर्ज़े",
@@ -800,7 +802,7 @@
"setups_baseline_hint": "पिछली सर्विस पर रीडिंग (वैकल्पिक)",
"baseline_start_help_edit": "मौजूदा गिनती बनाए रखने के लिए खाली छोड़ें। दर्ज किया गया मान गिनती को फिर से स्थिर करता है (जैसे पिछली सर्विस के समय की रीडिंग)।",
"baseline_current_effective": "वर्तमान में प्रभावी प्रारंभिक मान: {value}",
"runtime_on_states": "सक्रिय अवस्थाएँ (अल्पविराम से अलग)",
"runtime_on_states": "सक्रिय अवस्थाएँ",
"runtime_on_states_help": "वे अवस्थाएँ जो चालू समय में गिनी जाती हैं — डिफ़ॉल्ट: on। जैसे mowing, cleaning, printing। विशेषता चुनी होने पर उसकी मानों की तुलना होती है।",
"setups_target_new": "नया बनाएँ: {name}",
"schedule_preview_title": "अगली तिथियाँ",
@@ -178,6 +178,7 @@
"sort_type": "Típus",
"sort_task_name": "Feladat neve",
"all_objects": "Minden objektum",
"all_parts": "Összes alkatrész",
"tasks_lower": "feladat",
"no_tasks_yet": "Még nincs feladat",
"add_first_task": "Első feladat hozzáadása",
@@ -742,6 +743,7 @@
"part_restock_quantity": "Feltöltési mennyiség",
"part_auto_buy": "Vásárlási feladat automatikus létrehozása, ha kevés van",
"part_restock": "Készlet módosítása",
"parts_used_by": "Használja",
"restock_quantity_label": "Vásárolt mennyiség",
"consumes_parts_label": "Felhasznált alkatrészek",
"shared_parts_other_objects": "Más objektumok alkatrészei",
@@ -800,7 +802,7 @@
"setups_baseline_hint": "érték az utolsó szerviznél (opcionális)",
"baseline_start_help_edit": "Üresen hagyva a meglévő számlálás marad. Érték megadása újrahorgonyozza a számlálást (pl. az utolsó szerviz leolvasott értéke).",
"baseline_current_effective": "Jelenleg érvényes kezdőérték: {value}",
"runtime_on_states": "Aktív állapotok (vesszővel elválasztva)",
"runtime_on_states": "Aktív állapotok",
"runtime_on_states_help": "Működésként számító állapotok — alapértelmezés: on. Pl. mowing, cleaning, printing. Attribútum kiválasztásakor annak értékei számítanak.",
"setups_target_new": "Új létrehozása: {name}",
"schedule_preview_title": "Következő dátumok",
@@ -177,6 +177,7 @@
"sort_type": "Tipo",
"sort_task_name": "Nome attività",
"all_objects": "Tutti gli oggetti",
"all_parts": "Tutti i ricambi",
"tasks_lower": "attività",
"no_tasks_yet": "Nessuna attività",
"add_first_task": "Aggiungi prima attività",
@@ -736,6 +737,7 @@
"part_restock_quantity": "Quantità di riordino",
"part_auto_buy": "Task d'acquisto se scarso",
"part_restock": "Correggi scorta",
"parts_used_by": "Usato da",
"restock_quantity_label": "Quantità acquistata",
"consumes_parts_label": "Consuma ricambi",
"shared_parts_other_objects": "Ricambi di altri oggetti",
@@ -800,7 +802,7 @@
"setups_baseline_hint": "lettura all'ultima manutenzione (facoltativo)",
"baseline_start_help_edit": "Lascia vuoto per mantenere il conteggio esistente. Un valore inserito riancora il conteggio (ad es. la lettura dell'ultima manutenzione).",
"baseline_current_effective": "Valore iniziale attualmente in vigore: {value}",
"runtime_on_states": "Stati attivi (separati da virgole)",
"runtime_on_states": "Stati attivi",
"runtime_on_states_help": "Stati che contano come in funzione — predefinito: on. Ad es. mowing, cleaning, printing. Con un attributo selezionato vengono confrontati i suoi valori.",
"setups_target_new": "Crea nuovo: {name}",
"schedule_preview_title": "Prossime date",
@@ -178,6 +178,7 @@
"sort_type": "種別",
"sort_task_name": "タスク名",
"all_objects": "すべての対象",
"all_parts": "すべての部品",
"tasks_lower": "タスク",
"no_tasks_yet": "タスクがまだありません",
"add_first_task": "最初のタスクを追加",
@@ -736,6 +737,7 @@
"part_restock_quantity": "補充数量",
"part_auto_buy": "在庫少で購入タスク",
"part_restock": "在庫を調整",
"parts_used_by": "使用タスク",
"restock_quantity_label": "購入数量",
"consumes_parts_label": "部品を消費",
"shared_parts_other_objects": "他の対象の部品",
@@ -800,7 +802,7 @@
"setups_baseline_hint": "前回整備時の値(任意)",
"baseline_start_help_edit": "空欄のままにすると現在のカウントを維持します。値を入力するとカウントの起点を再設定します(例:前回整備時の値)。",
"baseline_current_effective": "現在有効な開始値:{value}",
"runtime_on_states": "稼働中とみなす状態(カンマ区切り)",
"runtime_on_states": "稼働中とみなす状態",
"runtime_on_states_help": "稼働時間として数える状態 — 既定値:on。例:mowing, cleaning, printing。属性を選択している場合はその値が比較されます。",
"setups_target_new": "新規作成:{name}",
"schedule_preview_title": "次回の予定日",
@@ -178,6 +178,7 @@
"sort_type": "유형",
"sort_task_name": "작업 이름",
"all_objects": "모든 객체",
"all_parts": "모든 부품",
"tasks_lower": "작업",
"no_tasks_yet": "아직 작업 없음",
"add_first_task": "첫 작업 추가",
@@ -742,6 +743,7 @@
"part_restock_quantity": "보충 수량",
"part_auto_buy": "재고 부족 시 구매 작업 자동 생성",
"part_restock": "재고 조정",
"parts_used_by": "사용처",
"restock_quantity_label": "구매 수량",
"consumes_parts_label": "사용하는 부품",
"shared_parts_other_objects": "다른 객체의 부품",
@@ -800,7 +802,7 @@
"setups_baseline_hint": "마지막 정비 시점의 값 (선택)",
"baseline_start_help_edit": "비워두면 기존 계수를 유지합니다. 값을 입력하면 계수 기준이 다시 설정됩니다 (예: 마지막 정비 시점의 값).",
"baseline_current_effective": "현재 적용 중인 시작 값: {value}",
"runtime_on_states": "활성 상태 (쉼표로 구분)",
"runtime_on_states": "활성 상태",
"runtime_on_states_help": "가동 중으로 계산할 상태입니다 — 기본값: on. 예: mowing, cleaning, printing. 속성을 선택한 경우 해당 속성 값과 비교합니다.",
"setups_target_new": "새로 만들기: {name}",
"schedule_preview_title": "다음 예정일",
@@ -178,6 +178,7 @@
"sort_type": "Type",
"sort_task_name": "Oppgavenavn",
"all_objects": "Alle objekter",
"all_parts": "Alle deler",
"tasks_lower": "oppgaver",
"no_tasks_yet": "Ingen oppgaver ennå",
"add_first_task": "Legg til første oppgave",
@@ -736,6 +737,7 @@
"part_restock_quantity": "Påfyllingsmengde",
"part_auto_buy": "Kjøpsoppgave ved lavt lager",
"part_restock": "Juster lager",
"parts_used_by": "Brukes av",
"restock_quantity_label": "Kjøpt mengde",
"consumes_parts_label": "Forbruker deler",
"shared_parts_other_objects": "Deler fra andre objekter",
@@ -800,7 +802,7 @@
"setups_baseline_hint": "avlesning ved forrige service (valgfritt)",
"baseline_start_help_edit": "La stå tomt for å beholde den eksisterende tellingen. En angitt verdi forankrer tellingen på nytt (f.eks. avlesningen ved forrige service).",
"baseline_current_effective": "Gjeldende startverdi: {value}",
"runtime_on_states": "Aktive tilstander (kommaseparert)",
"runtime_on_states": "Aktive tilstander",
"runtime_on_states_help": "Tilstander som teller som driftstid — standard: on. F.eks. mowing, cleaning, printing. Med et valgt attributt sammenlignes verdiene dets i stedet.",
"setups_target_new": "Opprett ny: {name}",
"schedule_preview_title": "Kommende datoer",
@@ -177,6 +177,7 @@
"sort_type": "Type",
"sort_task_name": "Taaknaam",
"all_objects": "Alle objecten",
"all_parts": "Alle onderdelen",
"tasks_lower": "taken",
"no_tasks_yet": "Nog geen taken",
"add_first_task": "Eerste taak toevoegen",
@@ -736,6 +737,7 @@
"part_restock_quantity": "Aanvulhoeveelheid",
"part_auto_buy": "Kooptaak bij lage voorraad",
"part_restock": "Voorraad aanpassen",
"parts_used_by": "Gebruikt door",
"restock_quantity_label": "Gekochte hoeveelheid",
"consumes_parts_label": "Verbruikt onderdelen",
"shared_parts_other_objects": "Onderdelen van andere objecten",
@@ -800,7 +802,7 @@
"setups_baseline_hint": "stand bij laatste onderhoud (optioneel)",
"baseline_start_help_edit": "Laat leeg om de bestaande telling te behouden. Een ingevulde waarde verankert de telling opnieuw (bijv. de stand bij de laatste servicebeurt).",
"baseline_current_effective": "Momenteel geldende startwaarde: {value}",
"runtime_on_states": "Actieve statussen (kommagescheiden)",
"runtime_on_states": "Actieve statussen",
"runtime_on_states_help": "Statussen die als draaitijd tellen — standaard: on. Bijv. mowing, cleaning, printing. Met een geselecteerd attribuut worden de waarden daarvan vergeleken.",
"setups_target_new": "Nieuw aanmaken: {name}",
"schedule_preview_title": "Volgende data",
@@ -177,6 +177,7 @@
"sort_type": "Typ",
"sort_task_name": "Nazwa zadania",
"all_objects": "Wszystkie obiekty",
"all_parts": "Wszystkie części",
"tasks_lower": "zadań",
"no_tasks_yet": "Jeszcze brak zadań",
"add_first_task": "Dodaj pierwsze zadanie",
@@ -736,6 +737,7 @@
"part_restock_quantity": "Ilość uzupełnienia",
"part_auto_buy": "Zadanie zakupu przy niskim stanie",
"part_restock": "Koryguj stan",
"parts_used_by": "Używane przez",
"restock_quantity_label": "Kupiona ilość",
"consumes_parts_label": "Zużywa części",
"shared_parts_other_objects": "Części z innych obiektów",
@@ -800,7 +802,7 @@
"setups_baseline_hint": "stan przy ostatnim serwisie (opcjonalnie)",
"baseline_start_help_edit": "Pozostaw puste, aby zachować dotychczasowe liczenie. Wpisana wartość zakotwicza liczenie na nowo (np. stan przy ostatnim serwisie).",
"baseline_current_effective": "Obecnie obowiązująca wartość początkowa: {value}",
"runtime_on_states": "Stany aktywne (rozdzielone przecinkami)",
"runtime_on_states": "Stany aktywne",
"runtime_on_states_help": "Stany liczone jako czas pracy — domyślnie: on. Np. mowing, cleaning, printing. Przy wybranym atrybucie porównywane są jego wartości.",
"setups_target_new": "Utwórz nowy: {name}",
"schedule_preview_title": "Najbliższe terminy",
@@ -178,6 +178,7 @@
"sort_type": "Tipo",
"sort_task_name": "Nome da tarefa",
"all_objects": "Todos os objetos",
"all_parts": "Todas as peças",
"tasks_lower": "tarefas",
"no_tasks_yet": "Ainda sem tarefas",
"add_first_task": "Adicionar a primeira tarefa",
@@ -742,6 +743,7 @@
"part_restock_quantity": "Quantidade de reposição",
"part_auto_buy": "Criar tarefa de compra automaticamente quando estiver baixo",
"part_restock": "Ajustar estoque",
"parts_used_by": "Usado por",
"restock_quantity_label": "Quantidade comprada",
"consumes_parts_label": "Consome peças",
"shared_parts_other_objects": "Peças de outros objetos",
@@ -800,7 +802,7 @@
"setups_baseline_hint": "leitura na última manutenção (opcional)",
"baseline_start_help_edit": "Deixe vazio para manter a contagem existente. Informar um valor reancora a contagem (ex.: a leitura da última manutenção).",
"baseline_current_effective": "Valor inicial efetivo atual: {value}",
"runtime_on_states": "Estados ativos (separados por vírgula)",
"runtime_on_states": "Estados ativos",
"runtime_on_states_help": "Estados que contam como em funcionamento — padrão: on. Ex.: mowing, cleaning, printing. Com um atributo selecionado, os valores dele são comparados.",
"setups_target_new": "Criar novo: {name}",
"schedule_preview_title": "Próximas datas",
@@ -177,6 +177,7 @@
"sort_type": "Tipo",
"sort_task_name": "Nome da tarefa",
"all_objects": "Todos os objetos",
"all_parts": "Todas as peças",
"tasks_lower": "tarefas",
"no_tasks_yet": "Ainda sem tarefas",
"add_first_task": "Adicionar primeira tarefa",
@@ -736,6 +737,7 @@
"part_restock_quantity": "Quantidade de reposição",
"part_auto_buy": "Tarefa de compra se baixo",
"part_restock": "Ajustar estoque",
"parts_used_by": "Usado por",
"restock_quantity_label": "Quantidade comprada",
"consumes_parts_label": "Consome peças",
"shared_parts_other_objects": "Peças de outros objetos",
@@ -800,7 +802,7 @@
"setups_baseline_hint": "leitura na última manutenção (opcional)",
"baseline_start_help_edit": "Deixe vazio para manter a contagem existente. Um valor introduzido reancora a contagem (p. ex., a leitura da última manutenção).",
"baseline_current_effective": "Valor inicial atualmente em vigor: {value}",
"runtime_on_states": "Estados ativos (separados por vírgulas)",
"runtime_on_states": "Estados ativos",
"runtime_on_states_help": "Estados que contam como em funcionamento — padrão: on. P. ex. mowing, cleaning, printing. Com um atributo selecionado, são comparados os valores dele.",
"setups_target_new": "Criar novo: {name}",
"schedule_preview_title": "Próximas datas",
@@ -176,6 +176,7 @@
"sort_type": "Тип",
"sort_task_name": "Имя задачи",
"all_objects": "Все объекты",
"all_parts": "Все детали",
"tasks_lower": "задач",
"no_tasks_yet": "Пока нет задач",
"add_first_task": "Добавить первую задачу",
@@ -736,6 +737,7 @@
"part_restock_quantity": "Количество пополнения",
"part_auto_buy": "Задача покупки при малом запасе",
"part_restock": "Изменить запас",
"parts_used_by": "Используется",
"restock_quantity_label": "Куплено, шт.",
"consumes_parts_label": "Расходует детали",
"shared_parts_other_objects": "Детали других объектов",
@@ -800,7 +802,7 @@
"setups_baseline_hint": "показание при последнем обслуживании (необязательно)",
"baseline_start_help_edit": "Оставьте пустым, чтобы сохранить текущий отсчёт. Введённое значение заново привязывает отсчёт (например, показание при последнем обслуживании).",
"baseline_current_effective": "Действующее начальное значение: {value}",
"runtime_on_states": "Активные состояния (через запятую)",
"runtime_on_states": "Активные состояния",
"runtime_on_states_help": "Состояния, засчитываемые как работа — по умолчанию: on. Напр. mowing, cleaning, printing. Если выбран атрибут, сравниваются его значения.",
"setups_target_new": "Создать новый: {name}",
"schedule_preview_title": "Ближайшие даты",
@@ -177,6 +177,7 @@
"sort_type": "Typ",
"sort_task_name": "Uppgiftsnamn",
"all_objects": "Alla objekt",
"all_parts": "Alla delar",
"tasks_lower": "uppgifter",
"no_tasks_yet": "Inga uppgifter ännu",
"add_first_task": "Lägg till första uppgift",
@@ -736,6 +737,7 @@
"part_restock_quantity": "Påfyllningsantal",
"part_auto_buy": "Köpuppgift vid lågt lager",
"part_restock": "Justera lager",
"parts_used_by": "Används av",
"restock_quantity_label": "Köpt antal",
"consumes_parts_label": "Förbrukar delar",
"shared_parts_other_objects": "Delar från andra objekt",
@@ -800,7 +802,7 @@
"setups_baseline_hint": "mätarställning vid senaste service (valfritt)",
"baseline_start_help_edit": "Lämna tomt för att behålla den befintliga räkningen. Ett angivet värde förankrar räkningen på nytt (t.ex. mätarställningen vid senaste servicen).",
"baseline_current_effective": "Nu gällande startvärde: {value}",
"runtime_on_states": "Aktiva tillstånd (kommaseparerade)",
"runtime_on_states": "Aktiva tillstånd",
"runtime_on_states_help": "Tillstånd som räknas som drifttid — standard: on. T.ex. mowing, cleaning, printing. Med ett valt attribut jämförs dess värden i stället.",
"setups_target_new": "Skapa ny: {name}",
"schedule_preview_title": "Kommande datum",
@@ -178,6 +178,7 @@
"sort_type": "Tür",
"sort_task_name": "Görev adı",
"all_objects": "Tüm nesneler",
"all_parts": "Tüm parçalar",
"tasks_lower": "görev",
"no_tasks_yet": "Henüz görev yok",
"add_first_task": "İlk görevi ekle",
@@ -742,6 +743,7 @@
"part_restock_quantity": "Stok yenileme miktarı",
"part_auto_buy": "Azaldığında otomatik satın alma görevi oluştur",
"part_restock": "Stoku ayarla",
"parts_used_by": "Kullanan",
"restock_quantity_label": "Satın alınan miktar",
"consumes_parts_label": "Parça tüketir",
"shared_parts_other_objects": "Diğer nesnelerin parçaları",
@@ -800,7 +802,7 @@
"setups_baseline_hint": "son servisteki okuma (isteğe bağlı)",
"baseline_start_help_edit": "Mevcut sayımı korumak için boş bırakın. Bir değer girmek sayımı yeniden sabitler (örn. son servisteki okuma).",
"baseline_current_effective": "Şu anda geçerli başlangıç değeri: {value}",
"runtime_on_states": "Etkin durumlar (virgülle ayrılmış)",
"runtime_on_states": "Etkin durumlar",
"runtime_on_states_help": "Çalışıyor sayılan durumlar — varsayılan: on. Örn. mowing, cleaning, printing. Bir öznitelik seçiliyse onun değerleri eşleştirilir.",
"setups_target_new": "Yeni oluştur: {name}",
"schedule_preview_title": "Sonraki tarihler",
@@ -177,6 +177,7 @@
"sort_type": "Тип",
"sort_task_name": "Назва завдання",
"all_objects": "Всі об'єкти",
"all_parts": "Усі деталі",
"tasks_lower": "завдань",
"no_tasks_yet": "Завдань ще немає",
"add_first_task": "Додати перше завдання",
@@ -736,6 +737,7 @@
"part_restock_quantity": "Кількість поповнення",
"part_auto_buy": "Завдання купівлі при малому запасі",
"part_restock": "Змінити запас",
"parts_used_by": "Використовується",
"restock_quantity_label": "Куплена кількість",
"consumes_parts_label": "Витрачає деталі",
"shared_parts_other_objects": "Деталі інших об'єктів",
@@ -800,7 +802,7 @@
"setups_baseline_hint": "показання під час останнього обслуговування (необов'язково)",
"baseline_start_help_edit": "Залиште порожнім, щоб зберегти поточний відлік. Введене значення заново прив'язує відлік (наприклад, показання під час останнього обслуговування).",
"baseline_current_effective": "Чинне початкове значення: {value}",
"runtime_on_states": "Активні стани (через кому)",
"runtime_on_states": "Активні стани",
"runtime_on_states_help": "Стани, що зараховуються як робота — типово: on. Напр. mowing, cleaning, printing. Якщо вибрано атрибут, порівнюються його значення.",
"setups_target_new": "Створити новий: {name}",
"schedule_preview_title": "Найближчі дати",
@@ -178,6 +178,7 @@
"sort_type": "类型",
"sort_task_name": "任务名称",
"all_objects": "所有维护项",
"all_parts": "所有配件",
"tasks_lower": "任务",
"no_tasks_yet": "尚无任务",
"add_first_task": "添加首个任务",
@@ -736,6 +737,7 @@
"part_restock_quantity": "补货数量",
"part_auto_buy": "库存低时自动建购买任务",
"part_restock": "调整库存",
"parts_used_by": "使用于",
"restock_quantity_label": "购买数量",
"consumes_parts_label": "消耗配件",
"shared_parts_other_objects": "其他设备的配件",
@@ -800,7 +802,7 @@
"setups_baseline_hint": "上次保养时的读数(可选)",
"baseline_start_help_edit": "留空以保留现有计数。填写数值会重新锚定计数(例如上次保养时的读数)。",
"baseline_current_effective": "当前生效的起始读数:{value}",
"runtime_on_states": "活动状态(逗号分隔)",
"runtime_on_states": "活动状态",
"runtime_on_states_help": "计入运行时间的状态 — 默认:on。例如 mowing、cleaning、printing。选择了属性时将比较该属性的值。",
"setups_target_new": "新建:{name}",
"schedule_preview_title": "下次日期",
@@ -1142,6 +1142,7 @@ function registerLlCustomHandler(): void {
cost: (histEntry.cost as number | undefined) ?? null,
duration: (histEntry.duration as number | undefined) ?? null,
completed_by: (histEntry.completed_by as string) ?? null,
used_parts: (histEntry.used_parts as Array<{ part_id: string; name?: string; quantity: number; entry_id?: string }> | null) ?? null,
});
} catch {
deepLink("/maintenance-supporter");
@@ -79,7 +79,32 @@ import { renderUserBadge, type TaskDetailContext } from "./renderers/task-detail
import "./components/task-detail-view";
import { computeWindow, VIRTUAL_MIN_ROWS } from "./helpers/virtual-window";
type View = "overview" | "object" | "task" | "all_objects";
type View = "overview" | "object" | "task" | "all_objects" | "all_parts";
/** One row of the instance-wide parts overview (#130) the WS response of
* `parts/overview`: the stored part fields plus owner, live stock and the
* consuming tasks (own + pooled #111 links). */
interface PartsOverviewRow {
part_id: string;
entry_id: string;
object_name: string | null;
name: string;
unit?: string | null;
cost?: number | null;
storage_location?: string | null;
vendor?: string | null;
reorder_threshold?: number | null;
stock: number | null;
low: boolean;
consumers: Array<{
entry_id: string;
object_name: string | null;
task_id: string;
task_name: string | null;
quantity: number;
pooled: boolean;
}>;
}
type SortMode = "due_date" | "object" | "type" | "task_name" | "area" | "assigned_user" | "group";
type ObjectSortMode = "alphabetical" | "due_soonest" | "task_count";
type GroupByMode = "none" | "area" | "group" | "user";
@@ -95,6 +120,8 @@ export class MaintenanceSupporterPanel extends LitElement {
@state() private _objects: MaintenanceObjectResponse[] = [];
@state() private _stats: StatisticsResponse | null = null;
@state() private _view: View = "overview";
// #130: rows of the all-parts view; null until first load.
@state() private _allParts: PartsOverviewRow[] | null = null;
@state() private _selectedEntryId: string | null = null;
@state() private _selectedTaskId: string | null = null;
@state() private _filterStatus = "";
@@ -813,6 +840,7 @@ export class MaintenanceSupporterPanel extends LitElement {
this._selectedEntryId = s.msp_entry || null;
this._selectedTaskId = s.msp_task || null;
this._moreMenuOpen = false;
if (s.msp_view === "all_parts") void this._loadAllParts();
if (s.msp_view === "task" && s.msp_entry && s.msp_task) {
this._historyFilter = null;
const task = this._getTask(s.msp_entry, s.msp_task);
@@ -839,6 +867,27 @@ export class MaintenanceSupporterPanel extends LitElement {
this._scrollContentToTop();
}
// #130: instance-wide parts inventory, sibling view of All objects.
private _showAllParts(): void {
this._pushPanelState("all_parts");
this._view = "all_parts";
this._selectedEntryId = null;
this._selectedTaskId = null;
this._scrollContentToTop();
void this._loadAllParts();
}
private async _loadAllParts(): Promise<void> {
try {
const result = await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/parts/overview",
}) as { parts: PartsOverviewRow[] };
this._allParts = result.parts || [];
} catch {
this._allParts = [];
}
}
/** v2.1.0 (Discussion #49 @byoung79): tap a KPI value to auto-filter
* the task list. Empty string clears the filter (used by "Tasks" KPI). */
private _filterByStatus(status: string): void {
@@ -1990,6 +2039,8 @@ export class MaintenanceSupporterPanel extends LitElement {
? this._renderOverview()
: this._view === "all_objects"
? this._renderAllObjects()
: this._view === "all_parts"
? this._renderAllParts()
: this._view === "object"
? this._renderObjectDetail()
: this._renderTaskDetail()}
@@ -2703,6 +2754,9 @@ export class MaintenanceSupporterPanel extends LitElement {
<ha-icon icon="mdi:arrow-left"></ha-icon>
</ha-icon-button>
<span>${t("all_objects", L)}</span>
<button class="sibling-view-chip" @click=${() => this._showAllParts()}>
<ha-icon icon="mdi:package-variant-closed"></ha-icon> ${t("all_parts", L)}
</button>
</div>
<div class="filter-bar">
<label class="filter-field">
@@ -2792,6 +2846,106 @@ export class MaintenanceSupporterPanel extends LitElement {
localStorage.setItem(LS_KEYS.objectView, mode);
}
// ── #130: instance-wide parts overview ────────────────────────────────────
private _renderAllParts() {
const L = this._lang;
const rows = this._allParts;
const currency = this._budget?.currency_symbol || "";
return html`
<div class="breadcrumb">
<ha-icon-button @click=${() => this._showAllObjects()}>
<ha-icon icon="mdi:arrow-left"></ha-icon>
</ha-icon-button>
<span>${t("all_parts", L)}</span>
<button class="sibling-view-chip" @click=${() => this._showAllObjects()}>
<ha-icon icon="mdi:devices"></ha-icon> ${t("all_objects", L)}
</button>
</div>
<div class="filter-bar">
<ha-button appearance="plain" @click=${() => this._exportPartsCsv()}>
<ha-icon icon="mdi:file-delimited-outline"></ha-icon> ${t("settings_export_csv", L)}
</ha-button>
</div>
${rows === null
? html`<div class="empty-state">…</div>`
: rows.length === 0
? html`<div class="empty-state">${t("parts_section", L)}: 0</div>`
: html`
<div class="objects-table-wrap">
<table class="objects-table">
<thead>
<tr>
<th>${t("part_name", L)}</th>
<th>${t("object", L)}</th>
<th>${t("part_stock", L)}</th>
<th>${t("part_reorder_threshold", L)}</th>
<th>${t("part_cost", L)}</th>
<th>${t("part_storage_location", L)}</th>
<th>${t("parts_used_by", L)}</th>
</tr>
</thead>
<tbody>
${rows.map((row) => html`
<tr class="objects-table-row" @click=${() => this._showObject(row.entry_id)}>
<td>
<span class="objects-table-name">${row.name}</span>
${row.low
? html`<ha-icon class="part-low-icon" icon="mdi:cart-arrow-down"
title="${t("part_reorder_threshold", L)}: ${row.reorder_threshold}"></ha-icon>`
: nothing}
</td>
<td>${row.object_name || "—"}</td>
<td>${row.stock !== null ? `${row.stock}${row.unit ? ` ${row.unit}` : ""}` : "—"}</td>
<td>${row.reorder_threshold ?? "—"}</td>
<td>${row.cost != null ? `${row.cost} ${currency}`.trim() : "—"}</td>
<td>${row.storage_location || "—"}</td>
<td>
${row.consumers.length === 0
? "—"
: row.consumers.map((c) => html`
<span
class="part-consumer-chip${c.pooled ? " pooled" : ""}"
title=${`${c.object_name ?? ""}: ${c.task_name ?? c.task_id} (×${c.quantity})`}
>${c.pooled ? `${c.object_name} · ` : ""}${c.task_name ?? c.task_id}</span>
`)}
</td>
</tr>
`)}
</tbody>
</table>
</div>
`}
`;
}
/** Client-side CSV of the loaded overview rows (the objects table's CSV is
* server-built; the parts rows are already fully materialized here). */
private _exportPartsCsv(): void {
const rows = this._allParts || [];
const esc = (v: unknown): string => {
const s = v == null ? "" : String(v);
return /[",\n;]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
};
const header = ["name", "object", "stock", "unit", "reorder_threshold", "unit_cost", "storage_location", "vendor", "used_by"];
const lines = [header.join(",")];
for (const row of rows) {
lines.push([
esc(row.name),
esc(row.object_name),
esc(row.stock),
esc(row.unit),
esc(row.reorder_threshold),
esc(row.cost),
esc(row.storage_location),
esc(row.vendor),
esc(row.consumers.map((c) => `${c.object_name ?? ""}/${c.task_name ?? c.task_id}×${c.quantity}`).join(" | ")),
].join(","));
}
const ts = new Date().toISOString().slice(0, 10);
downloadTextFile(lines.join("\n"), `maintenance_parts_${ts}.csv`, "text/csv;charset=utf-8");
}
// (#67 / Phase 3) Download all objects as a one-row-per-object CSV.
private async _exportObjectsCsv(): Promise<void> {
try {
@@ -3602,6 +3756,7 @@ export class MaintenanceSupporterPanel extends LitElement {
cost: entry.cost ?? null,
duration: entry.duration ?? null,
completed_by: entry.completed_by ?? null,
used_parts: entry.used_parts ?? null,
};
this.shadowRoot
?.querySelector<MaintenanceHistoryEditDialog>("maintenance-history-edit-dialog")
@@ -605,6 +605,43 @@ export const panelStyles = css`
}
.view-toggle-btn ha-icon { --mdc-icon-size: 18px; }
/* (#130) All-parts view: sibling chip in the breadcrumb + table extras */
.sibling-view-chip {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: 12px;
padding: 4px 10px;
border: 1px solid var(--divider-color);
border-radius: 14px;
background: none;
color: var(--secondary-text-color);
font: inherit;
font-size: 13px;
cursor: pointer;
}
.sibling-view-chip:hover {
background: var(--secondary-background-color, rgba(0, 0, 0, 0.04));
color: var(--primary-text-color);
}
.sibling-view-chip ha-icon { --mdc-icon-size: 16px; }
.part-low-icon {
--mdc-icon-size: 16px;
color: var(--warning-color, #ff9800);
vertical-align: middle;
margin-left: 6px;
}
.part-consumer-chip {
display: inline-block;
margin: 1px 4px 1px 0;
padding: 1px 8px;
border: 1px solid var(--divider-color);
border-radius: 10px;
font-size: 12px;
color: var(--secondary-text-color);
}
.part-consumer-chip.pooled { border-style: dashed; }
/* (#67) Objects table (desktop All-Objects view) */
.objects-table-wrap {
overflow-x: auto;
@@ -94,6 +94,8 @@ export interface HistoryEntry {
photo_doc_id?: string | null;
/** v2.20 (#83): recorded value for `reading`-type tasks. */
reading_value?: number | null;
/** #99/#130: the completion's part consumption (entry_id set for pooled). */
used_parts?: Array<{ part_id: string; name?: string; quantity: number; entry_id?: string }> | null;
/** v2.37: completion recorded by the system itself (trigger recovered),
* not performed by a user in the UI. */
auto?: boolean;