/
Starolat
/
DeepDive
Обзор
Документация
Войти
/
Starolat
/
DeepDive
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
js/components/summary/VolumeAutoWidget.js
513 строк
15 KB
Starolat Sergei
fix: скрытие пустых виджетов на авто-уровне и вечный скелетон после фильтрации
14 июл 2026, 08:19
14 июл 2026, 08:19
6224b10
Код
Авторство
О чём код?
// @ts-check /** * @fileoverview VolumeAutoWidget — адаптивный виджет для auto-уровней material-домена. * В компактном режиме (высота ≤130px) показывает lightweight summary по физическим объёмам. * В развёрнутом режиме (высота >130px) внутри себя монтирует * <volume-progress-widget> с полноценным списком материалов (pull-модель). * @version 1.0.0 * @element volume-auto-widget */ import { Package } from "../ConstantinIcons.js"; /** * @typedef {import('../../types.js').AutoLevelMetric} AutoLevelMetric * @typedef {import('../../types.js').Filter} Filter * @typedef {import('../../types.js').LevelContext} LevelContext */ export class VolumeAutoWidget extends HTMLElement { #widgetTitle = ""; #levelData = /** @type {AutoLevelMetric|null} */ (null); #drillable = false; #privateFilters = /** @type {Filter[]} */ ([]); #databaseId = /** @type {string|null} */ (null); #levelContext = /** @type {LevelContext|null} */ (null); #reportDate = /** @type {string|null} */ (null); #mode = /** @type {'light'|'full'} */ ("light"); #resizeObserver = /** @type {ResizeObserver|null} */ (null); static get observedAttributes() { return ["title", "drillable", "report-date"]; } constructor() { super(); this.attachShadow({ mode: "open" }); this._handleClick = this._handleClick.bind(this); } connectedCallback() { if (this.#levelData && this.#mode === "light") { this._render(); } else if (this.#mode === "light") { this.shadowRoot.innerHTML = ` <style> :host { display: flex; align-items: center; justify-content: center; height: 100%; color: var(--color-typo-ghost, #999); font-size: var(--size-text-xs, 10px); font-family: var(--font-primary, Inter, sans-serif); } </style> <span>Подготовка аналитики…</span> `; } this.#resizeObserver = new ResizeObserver(() => { this._adaptMode(); }); this.#resizeObserver.observe(this); } disconnectedCallback() { this._removeDrillDownClick(); if (this.#resizeObserver) { this.#resizeObserver.disconnect(); this.#resizeObserver = null; } } attributeChangedCallback(name, oldValue, newValue) { if (oldValue === newValue) return; switch (name) { case "title": this.#widgetTitle = newValue || ""; break; case "drillable": this.#drillable = newValue !== null && newValue !== "false"; break; case "report-date": this.#reportDate = newValue || null; break; } if (this.#mode === "full") { this._updateFullWidget(); } else if (this.shadowRoot && this.shadowRoot.innerHTML) { this._render(); } } get widgetTitle() { return this.#widgetTitle; } set widgetTitle(value) { this.#widgetTitle = value || ""; this.setAttribute("title", this.#widgetTitle); if (this.#mode === "full") { this._updateFullWidget(); } else if (this.shadowRoot) { this._render(); } } get levelData() { return this.#levelData; } set levelData(value) { const data = value || null; this.#levelData = data; if (this.#mode === "full") { // Full mode: forward the fresh metric to the inner // volume-progress-widget, otherwise it keeps stale/empty content. this._updateFullWidget(); } else if (this.shadowRoot && this.shadowRoot.innerHTML) { this._render(); } } get drillable() { return this.#drillable; } set drillable(value) { this.#drillable = Boolean(value); if (this.#mode === "full") { this._updateFullWidget(); } else if (this.shadowRoot) { this._render(); } } get privateFilters() { return this.#privateFilters; } set privateFilters(value) { this.#privateFilters = Array.isArray(value) ? value : []; if (this.#mode === "full") { this._updateFullWidget(); } } get databaseId() { return this.#databaseId; } set databaseId(value) { this.#databaseId = value || null; if (this.#mode === "full") { this._updateFullWidget(); } } get levelContext() { return this.#levelContext; } set levelContext(value) { this.#levelContext = value || null; if (this.#mode === "full") { this._updateFullWidget(); } } get reportDate() { return this.#reportDate; } set reportDate(value) { this.#reportDate = value || null; if (this.#mode === "full") { this._updateFullWidget(); } } // ==================== Mode Switching ==================== _adaptMode() { if (this.clientHeight > 130) { if (this.#mode !== "full") { this.#mode = "full"; this._enterFullMode(); } } else { if (this.#mode !== "light") { this.#mode = "light"; this._enterLightMode(); } } } _enterFullMode() { if (!customElements.get("volume-progress-widget")) { // Fallback to light mode if volume-progress-widget is not yet registered this.#mode = "light"; this._render(); return; } this._removeDrillDownClick(); this.shadowRoot.innerHTML = ` <style> :host { display: flex; flex-direction: column; flex: 1; min-width: 0; position: relative; box-sizing: border-box; } volume-progress-widget { flex: 1; min-height: 0; display: flex; flex-direction: column; width: 100%; } </style> `; const data = this.#levelData; const titleBase = data?.shortName && data?.description && data.shortName !== data.description ? `${data.shortName} — ${data.description}` : data?.shortName || data?.description || ""; const titleText = this.#widgetTitle || titleBase; const widget = document.createElement("volume-progress-widget"); widget.setAttribute("title", titleText); if (this.#drillable) widget.setAttribute("drillable", ""); widget.drillable = this.#drillable; widget.privateFilters = this.#privateFilters; if (this.#databaseId) widget.databaseId = this.#databaseId; if (this.#levelContext) widget.levelContext = this.#levelContext; if (this.#reportDate) widget.setAttribute("report-date", this.#reportDate); this.shadowRoot.appendChild(widget); } _enterLightMode() { if (this.#levelData) { this._render(); } else { this.shadowRoot.innerHTML = ` <style> :host { display: flex; align-items: center; justify-content: center; height: 100%; color: var(--color-typo-ghost, #999); font-size: var(--size-text-xs, 10px); font-family: var(--font-primary, Inter, sans-serif); } </style> <span>Подготовка аналитики…</span> `; } } _updateFullWidget() { const widget = this.shadowRoot?.querySelector("volume-progress-widget"); if (!widget) return; const data = this.#levelData; const titleBase = data?.shortName && data?.description && data.shortName !== data.description ? `${data.shortName} — ${data.description}` : data?.shortName || data?.description || ""; const titleText = this.#widgetTitle || titleBase; widget.setAttribute("title", titleText); widget.drillable = this.#drillable; widget.privateFilters = this.#privateFilters; if (this.#databaseId) widget.databaseId = this.#databaseId; if (this.#levelContext) widget.levelContext = this.#levelContext; if (this.#reportDate) widget.setAttribute("report-date", this.#reportDate); } // ==================== Rendering (Light) ==================== _render() { const data = this.#levelData; const shortName = data?.shortName || ""; const description = data?.description || ""; const titleBase = shortName && description && shortName !== description ? `${shortName} — ${description}` : shortName || description; const titleText = this._escapeHtml(this.#widgetTitle || titleBase); const planQty = data?.planQty ?? 0; const actualQty = data?.actualQty ?? 0; const unitName = data?.unitName || ""; const activityCount = data?.activityCount ?? data?.count ?? 0; const percentComplete = planQty > 0 ? parseFloat(((actualQty / planQty) * 100).toFixed(2)) : data?.avgPercentComplete ?? 0; const getPctColor = (/** @type {number} */ pct) => { if (pct < 50) return "var(--consta-alert, #eb5757)"; if (pct < 80) return "var(--warning-color, #f2c94c)"; return "var(--success-color, #22c38e)"; }; const barColor = getPctColor(percentComplete); const hasData = data && (planQty > 0 || actualQty > 0 || activityCount > 0); const formatNum = (/** @type {number|null|undefined} */ n) => n != null ? Number(n).toLocaleString("ru-RU", { maximumFractionDigits: 2 }) : "—"; this.shadowRoot.innerHTML = ` <style> :host { display: flex; flex-direction: column; flex: 1; min-width: 0; background: var(--color-bg-default, #fff); border: 1px solid var(--color-bg-border, #E0E0E0); border-radius: var(--radius-s, 4px); position: relative; padding: 4px 6px; font-family: var(--font-primary, Inter, sans-serif); gap: 2px; box-sizing: border-box; cursor: ${this.#drillable ? "pointer" : "default"}; } :host([drillable]) { cursor: pointer; } .widget-header { display: grid; grid-template-columns: 20px 1fr 20px; gap: 4px; align-items: center; border-bottom: 1px solid var(--color-bg-border, #E0E0E0); padding-bottom: 1px; min-height: 16px; } .icon { grid-column: 1; justify-self: start; width: 12px; height: 12px; color: var(--color-control-bg-primary, #0078D2); } .icon svg { width: 12px; height: 12px; } .title { grid-column: 2; color: var(--color-typo-primary, #333); font-size: var(--size-text-m, 12px); font-weight: 700; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .widget-body { display: flex; flex-direction: column; gap: 4px; flex: 1; justify-content: center; } .metrics-row { display: flex; justify-content: space-between; align-items: baseline; font-size: var(--size-text-xs, 10px); } .metric-label { color: var(--color-typo-secondary, #666); text-transform: uppercase; font-weight: 600; } .metric-value { color: var(--color-typo-primary, #333); font-weight: 700; } .progress-row { display: flex; align-items: center; gap: 6px; } .progress-track { flex: 1; height: 6px; background: var(--color-bg-secondary, #e0e0e0); border-radius: 3px; overflow: hidden; min-width: 0; } .progress-fill { height: 100%; border-radius: 3px; transition: width 0.3s ease; } .progress-label { font-size: var(--size-text-2xs, 9px); font-weight: 600; color: var(--color-typo-primary, #333); min-width: 32px; text-align: right; } .empty-state { flex: 1; display: flex; align-items: center; justify-content: center; color: var(--color-typo-secondary, #666); font-size: var(--size-text-xs, 10px); } </style> <div class="widget-header"> <span class="icon" data-no-drill>${Package}</span> <span class="title">${titleText}</span> <span></span> </div> ${ hasData ? ` <div class="widget-body"> <div class="metrics-row"> <span class="metric-label">План</span> <span class="metric-value">${formatNum(planQty)} ${this._escapeHtml(unitName)}</span> </div> <div class="metrics-row"> <span class="metric-label">Факт</span> <span class="metric-value">${formatNum(actualQty)} ${this._escapeHtml(unitName)}</span> </div> <div class="progress-row"> <div class="progress-track"> <div class="progress-fill" style="width:${Math.min(percentComplete, 100)}%; background:${barColor};"></div> </div> <span class="progress-label">${percentComplete.toFixed(1)}%</span> </div> <div class="metrics-row"> <span class="metric-label">Работ</span> <span class="metric-value">${activityCount}</span> </div> </div> ` : '<div class="empty-state">Нет данных</div>' } `; this._setupDrillDownClick(); } _setupDrillDownClick() { this._removeDrillDownClick(); if (this.#drillable && this.#levelData) { this.shadowRoot ?.querySelector(".widget-body") ?.addEventListener("click", this._handleClick); this.shadowRoot ?.querySelector(".widget-header") ?.addEventListener("click", this._handleClick); } } _removeDrillDownClick() { this.shadowRoot ?.querySelector(".widget-body") ?.removeEventListener("click", this._handleClick); this.shadowRoot ?.querySelector(".widget-header") ?.removeEventListener("click", this._handleClick); } _handleClick(e) { if (e.target.closest("[data-no-drill]")) return; if (!this.#drillable) return; const data = this.#levelData; if (!data) return; this.dispatchEvent( new CustomEvent("drill-down-request", { bubbles: true, composed: true, detail: { clickedField: this.#privateFilters?.[0]?.field || "", clickedValue: data.shortName || "", clickedDisplayName: data.shortName || "", privateFilters: this.#privateFilters || [], }, }), ); } _escapeHtml(str) { return String(str) .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, """); } } if (!customElements.get("volume-auto-widget")) { customElements.define("volume-auto-widget", VolumeAutoWidget); }