diff --git a/adk-rust/crates/detmir-portal/src/static/app.css b/adk-rust/crates/detmir-portal/src/static/app.css index 366446a..63b3dbb 100644 --- a/adk-rust/crates/detmir-portal/src/static/app.css +++ b/adk-rust/crates/detmir-portal/src/static/app.css @@ -183,6 +183,46 @@ h1 { min-height: 180px; } +.index-explain-card { + margin: 12px 0; +} + +.section-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.section-head h3 { + margin-bottom: 4px; +} + +.index-metrics { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 8px; + margin: 12px 0; +} + +.index-metrics > div { + display: grid; + gap: 4px; + min-height: 64px; + padding: 10px; + border: 1px solid var(--line); + border-radius: 8px; + background: #f8fafc; +} + +.app-weight-list { + margin-top: 8px; +} + +.app-weight-row { + grid-template-columns: minmax(140px, 1fr) minmax(260px, 2fr) auto; +} + .compact-list { gap: 6px; } @@ -192,6 +232,17 @@ h1 { padding: 8px; } +.report-section .compact-row { + grid-template-columns: 1fr; + align-items: flex-start; +} + +.row strong, +.row .muted { + min-width: 0; + overflow-wrap: anywhere; +} + .markdown-card { margin-top: 12px; } @@ -266,5 +317,7 @@ pre { .row { grid-template-columns: 1fr; } .incident-row { grid-template-columns: 1fr; } .evidence-row { grid-template-columns: 1fr; } + .app-weight-row { grid-template-columns: 1fr; } + .section-head { flex-direction: column; } .actions { justify-content: flex-start; } } diff --git a/adk-rust/crates/detmir-portal/src/static/app.js b/adk-rust/crates/detmir-portal/src/static/app.js index 4bc9609..068ccb2 100644 --- a/adk-rust/crates/detmir-portal/src/static/app.js +++ b/adk-rust/crates/detmir-portal/src/static/app.js @@ -117,7 +117,7 @@ function renderOperator(data) { `; } -function renderManager(data) { +function renderManager(data, reports) { const workforceIndex = workforceIndexText(data.users_count, data.total_active_seconds); return `

Руководитель

@@ -140,6 +140,7 @@ function renderManager(data) { `).join("")} + ${renderWorkforceIndexExplanation(reports?.workforce_policy)}

Сотрудники

${(data.users || []).map(user => `
@@ -159,6 +160,76 @@ function workforceIndexText(usersCount, activeSeconds) { return `${pct}%`; } +function humanSeconds(seconds) { + const value = Math.max(0, Number(seconds || 0)); + const totalMinutes = Math.round(value / 60); + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + return `${hours}:${String(minutes).padStart(2, "0")}`; +} + +function pctText(value) { + const n = Number(value); + if (!Number.isFinite(n)) return "0%"; + return `${Math.round(n * 100)}%`; +} + +function renderWorkforceIndexExplanation(policy) { + if (!policy || !policy.configured) { + return ` +
+

Почему такой индекс?

+

Role/application policy не настроена. Доступен только нейтральный индекс активности.

+
+ `; + } + const details = Array.isArray(policy.app_details) ? policy.app_details.slice(0, 12) : []; + const weightedTotal = Math.max(1, Number(policy.weighted_seconds || 0)); + const appRows = details.length === 0 + ? `
Нет приложенийНет top breakdown для weighted KPI
` + : details.map(item => { + const contribution = Math.round(Number(item.weighted_seconds || 0) / weightedTotal * 100); + return ` +
+ ${escapeHtml(item.application || "-")} + ${escapeHtml(humanSeconds(item.seconds))} · вес ${escapeHtml(pctText(item.weight))} · правило ${escapeHtml(item.matched_rule || "default_weight")} + ${escapeHtml(humanSeconds(item.weighted_seconds))} · ${contribution}% +
+ `; + }).join(""); + return ` +
+
+
+

Почему такой индекс?

+

${escapeHtml(policy.explanation || "Индекс = взвешенное время приложений / плановое время роли.")}

+
+ ${escapeHtml(workforceIndexTextFromValue(policy.index))} +
+
+
Роль${escapeHtml(policy.role_label || policy.role || "-")}
+
План${escapeHtml(humanSeconds(policy.planned_seconds))}
+
App time${escapeHtml(humanSeconds(policy.app_seconds))}
+
Weighted${escapeHtml(humanSeconds(policy.weighted_seconds))}
+
+
${appRows}
+
+ `; +} + +function workforceIndexTextFromValue(value) { + const n = Number(value); + return Number.isFinite(n) ? `${Math.round(n)}%` : "Нет данных"; +} + +function workforceIndexStatus(value) { + const n = Number(value); + if (!Number.isFinite(n)) return "UNKNOWN"; + if (n >= 80) return "OK"; + if (n >= 45) return "WARN"; + return "FAIL"; +} + function renderOwner(data) { const cards = Object.entries(data.cards || {}); return ` @@ -304,6 +375,7 @@ function renderReports(data) {

Ключевые показатели

${renderKpiCards(data.kpis)} + ${renderWorkforceIndexExplanation(data.workforce_policy)}

Срезы отчета

${renderReportSections(data.sections)}
@@ -320,7 +392,10 @@ async function refresh() { const content = document.getElementById("content"); const data = await loadJson(`/${state.tab}`); if (state.tab === "operator") content.innerHTML = renderOperator(data); - if (state.tab === "manager") content.innerHTML = renderManager(data); + if (state.tab === "manager") { + const reports = await loadJson("/reports").catch(() => null); + content.innerHTML = renderManager(data, reports); + } if (state.tab === "owner") content.innerHTML = renderOwner(data); if (state.tab === "incidents") { const evidence = await loadJson("/dlp/evidence").catch(error => ({ ok: false, error: error.message, items: [] })); diff --git a/docs/DETMIR_COMMERCIAL_MODULES_RU.md b/docs/DETMIR_COMMERCIAL_MODULES_RU.md index 48e0bea..ca0d71c 100644 --- a/docs/DETMIR_COMMERCIAL_MODULES_RU.md +++ b/docs/DETMIR_COMMERCIAL_MODULES_RU.md @@ -73,6 +73,10 @@ JSON отчета содержит объяснение расчета: - взвешенное время; - matched rule по каждому приложению из top breakdown. +В портале это раскрывается в экране `Почему такой индекс?`: собственник видит +роль, итоговый weighted KPI, план/app/weighted time и top-12 приложений с весом +и вкладом каждого приложения. + После изменения runtime-файла нужно перезапустить портал: ```bash diff --git a/docs/DETMIR_PORTAL_GUI_PLAN_RU.md b/docs/DETMIR_PORTAL_GUI_PLAN_RU.md index 97f5b62..0b70ae4 100644 --- a/docs/DETMIR_PORTAL_GUI_PLAN_RU.md +++ b/docs/DETMIR_PORTAL_GUI_PLAN_RU.md @@ -39,6 +39,9 @@ baseline и раздела `Phase 8: Post-MVP Enhancements`. `admin`, `manager`, `sales`; - JSON портала возвращает объяснение расчета weighted KPI: активную роль, каталог ролей, planned/app/weighted seconds и matched rule по приложениям; +- вкладки `Руководитель` и `Отчеты` показывают экран `Почему такой индекс?` + с ролью, формулой, плановым временем, app time, weighted time и top + приложениями с весом/правилом/вкладом; - отчет использует Worktime management snapshot и показывает сравнение подразделений/ответственных за текущий день; - JSON отчета содержит `workforce.department_comparison`,