refactor: implement dashboard UI enhancements, add health alert tooltips, and introduce modular JS application structure with Netlify data functions.
This commit is contained in:
+25
-10
@@ -71,7 +71,18 @@
|
||||
<div style="position:relative;height:260px"><canvas id="chartCurrentState"></canvas></div>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<h3>📢 Alertas de Salud</h3>
|
||||
<h3 style="display:flex;align-items:center;">📢 Alertas de Salud
|
||||
<div class="pop-trigger" style="margin-left:8px; font-size:14px; cursor:help; border:none;">ℹ️
|
||||
<div class="pop-content" style="text-transform:none; font-weight:normal; letter-spacing:normal; text-align:left; font-size:12px; width:260px;">
|
||||
<strong>Tipos de Alertas:</strong><br><br>
|
||||
<span class="badge" style="background:#ffab00;color:white;font-size:10px;padding:2px 4px;">ESTANCADO</span> › +14 días en curso.<br><br>
|
||||
<span class="badge" style="background:#ff5630;color:white;font-size:10px;padding:2px 4px;">VENCIDO</span> › Fecha de entrega caducada.<br><br>
|
||||
<span class="badge" style="background:#6554c0;color:white;font-size:10px;padding:2px 4px;">SIN SP</span> › Completado sin estimación.<br><br>
|
||||
<span class="badge" style="background:#ff8b00;color:white;font-size:10px;padding:2px 4px;">HUÉRFANO</span> › Ticket con SP sin asignar.<br><br>
|
||||
<span class="badge" style="background:#36b37e;color:white;font-size:10px;padding:2px 4px;">DEV LIBRE</span> › Dev sin tickets pendientes ni en curso.
|
||||
</div>
|
||||
</div>
|
||||
</h3>
|
||||
<div class="sub">Tickets estancados o sin estimación</div>
|
||||
<div id="warningsSection" style="display:none; overflow-y:auto; max-height:230px; margin-top:10px;">
|
||||
<ul class="warning-list" id="warningsList" style="padding:0; margin:0;"></ul>
|
||||
@@ -127,9 +138,12 @@
|
||||
</div>
|
||||
|
||||
<div class="table-card">
|
||||
<h3>SP Completados — Tabla Resumen</h3>
|
||||
<h3 style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<span>SP Completados — Tabla Resumen</span>
|
||||
<span id="teamAvgLabel" style="font-size:12px; font-weight:normal; background:#e6fcff; color:#0065ff; padding:4px 8px; border-radius:4px; display:none;"></span>
|
||||
</h3>
|
||||
<table>
|
||||
<thead><tr><th>Desarrollador</th><th>✅ SP Completados</th><th>🎟️ Tickets</th><th>% del impacto total</th></tr></thead>
|
||||
<thead><tr><th>Desarrollador</th><th>✅ SP Total</th><th>🎟️ Tickets</th><th>📉 Media SP/Ticket</th><th>% del impacto total vs Equipo</th></tr></thead>
|
||||
<tbody id="tableHistory"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -262,10 +276,10 @@
|
||||
</div>
|
||||
|
||||
<div class="kpi-row-3">
|
||||
<div class="kpi returned" title="Tickets que rebotaron en QA y el desarrollador tuvo que retomar ('Reaperturas' o regresos al ciclo En Curso)">
|
||||
<div class="kpi returned" title="Tickets que sufrieron rechazos en revisiones (Code Review, Ready to QA o QA) y volvieron a desarrollo.">
|
||||
<div class="kpi-value" id="kpi-qa-returned">—</div>
|
||||
<div class="kpi-label">Tickets Devueltos</div>
|
||||
<div class="kpi-sub" id="kpi-qa-returned-pct">Volvieron a En Curso</div>
|
||||
<div class="kpi-label">Tickets con Retrabajo</div>
|
||||
<div class="kpi-sub" id="kpi-qa-returned-pct">Sufrieron rechazos</div>
|
||||
</div>
|
||||
<div class="kpi qa-queue">
|
||||
<div class="kpi-value" id="kpi-qa-rtqa-p50">—</div>
|
||||
@@ -299,8 +313,8 @@
|
||||
<div style="position:relative;height:240px"><canvas id="chartQaDist"></canvas></div>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<h3>🔄 Devueltos vs. Aprobados</h3>
|
||||
<div class="sub">Tickets que volvieron a En Curso al menos una vez</div>
|
||||
<h3>🔄 Flujo Directo vs Retrabajo</h3>
|
||||
<div class="sub">Tickets con 0 rechazos vs con devoluciones (CR/QA)</div>
|
||||
<div style="position:relative;height:240px"><canvas id="chartQaReturned"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -327,8 +341,9 @@
|
||||
<tr>
|
||||
<th>Ticket</th>
|
||||
<th>Resumen</th>
|
||||
<th>⏱️ Tiempo en QA (hs)</th>
|
||||
<th>⏳ Cola RTQA (hs)</th>
|
||||
<th>⏱️ Dev (hs)</th>
|
||||
<th>⏱️ CR (hs)</th>
|
||||
<th>⏱️ QA (hs)</th>
|
||||
<th>🔄 Veces devuelto</th>
|
||||
<th>Estado</th>
|
||||
</tr>
|
||||
|
||||
@@ -238,6 +238,12 @@ function renderHistorySection() {
|
||||
let periodData = {};
|
||||
let periodTicketCount = {};
|
||||
|
||||
Object.keys(data.assigneeData || {}).forEach(p => {
|
||||
if (state.selectedDev !== 'all' && p !== state.selectedDev) return;
|
||||
periodData[p] = 0;
|
||||
periodTicketCount[p] = 0;
|
||||
});
|
||||
|
||||
data.issuesList.filter(i => i.status === 'done').forEach(i => {
|
||||
i.devs.forEach(p => {
|
||||
if (state.selectedDev !== 'all' && p !== state.selectedDev) return;
|
||||
@@ -247,37 +253,60 @@ function renderHistorySection() {
|
||||
});
|
||||
|
||||
const totalSP = Object.values(periodData).reduce((a,b) => a+b, 0);
|
||||
const devCount = Object.keys(periodData).filter(p => periodData[p] > 0).length;
|
||||
const devsWithActiveTickets = Object.keys(periodData).filter(p => periodData[p] > 0).length;
|
||||
// Promedio global del equipo usando total de devs (incluyendo los de 0) para medir impacto real
|
||||
const allDevsCount = Object.keys(periodData).length;
|
||||
const teamAvgSP = allDevsCount > 0 ? (totalSP / allDevsCount) : 0;
|
||||
|
||||
document.getElementById('kpi-done-period').textContent = totalSP;
|
||||
document.getElementById('kpi-devs-period').textContent = devCount;
|
||||
document.getElementById('kpi-avg-period').textContent = devCount > 0 ? Math.round(totalSP / devCount) : 0;
|
||||
const labelEl = document.getElementById('teamAvgLabel');
|
||||
if (labelEl && teamAvgSP > 0 && state.selectedDev === 'all') {
|
||||
labelEl.textContent = `🎯 Media Equipo: ${teamAvgSP.toFixed(1)} SP por Dev`;
|
||||
labelEl.style.display = 'inline-block';
|
||||
} else if (labelEl) {
|
||||
labelEl.style.display = 'none';
|
||||
}
|
||||
|
||||
// Gráfico de impacto personal (Terminados)
|
||||
const sorted = Object.entries(periodData).filter(([,sp]) => sp > 0).sort((a,b) => b[1]-a[1]);
|
||||
chartsManager.drawBarChart('doneDev', 'chartDoneDev', sorted.map(([p]) => shortName(p)), [{
|
||||
label: 'SP entregados', data: sorted.map(([,sp]) => sp),
|
||||
backgroundColor: sorted.map(([p]) => chartsManager.personColor(p)), borderRadius: 5
|
||||
document.getElementById('kpi-done-period').textContent = totalSP;
|
||||
document.getElementById('kpi-devs-period').textContent = devsWithActiveTickets;
|
||||
document.getElementById('kpi-avg-period').textContent = devsWithActiveTickets > 0 ? Math.round(totalSP / devsWithActiveTickets) : 0;
|
||||
|
||||
// Gráfico de impacto personal (Terminados) (solo mayores a 0)
|
||||
const sortedGraph = Object.entries(periodData).filter(([,sp]) => sp > 0).sort((a,b) => b[1]-a[1]);
|
||||
chartsManager.drawBarChart('doneDev', 'chartDoneDev', sortedGraph.map(([p]) => shortName(p)), [{
|
||||
label: 'SP entregados', data: sortedGraph.map(([,sp]) => sp),
|
||||
backgroundColor: sortedGraph.map(([p]) => chartsManager.personColor(p)), borderRadius: 5
|
||||
}], { plugins:{ legend:{ display:false } }, scales:{ x:{grid:{display:false}} } });
|
||||
|
||||
// Tabla de Resultados
|
||||
const sortedAll = Object.entries(periodData).sort((a,b) => b[1]-a[1]);
|
||||
const tbody = document.getElementById('tableHistory');
|
||||
tbody.innerHTML = '';
|
||||
if (sorted.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="4" class="no-data">No hay entregas en este período</td></tr>';
|
||||
if (sortedAll.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="no-data">No hay entregas en este período</td></tr>';
|
||||
} else {
|
||||
sorted.forEach(([p, sp]) => {
|
||||
const p_ = totalSP > 0 ? (sp / totalSP * 100).toFixed(1) : 0;
|
||||
sortedAll.forEach(([p, sp]) => {
|
||||
const p_vsTeam = teamAvgSP > 0 ? (sp / teamAvgSP * 100).toFixed(0) : 0;
|
||||
const c = chartsManager.personColor(p);
|
||||
const tickets = periodTicketCount[p] || 0;
|
||||
const avgPerTicket = tickets > 0 ? (sp / tickets).toFixed(1) : 0;
|
||||
|
||||
let badgeHtml = '';
|
||||
if (sp === 0) {
|
||||
badgeHtml = `<span class="badge" style="background:#ff5630;color:white;font-size:10px;">0 SP (SIN ENTREGAS)</span>`;
|
||||
} else if (p_vsTeam >= 150) {
|
||||
badgeHtml = `<span style="font-size:12px;color:#36b37e;font-weight:bold;">🔥 +${p_vsTeam - 100}% sup. prom.</span>`;
|
||||
} else if (p_vsTeam <= 50) {
|
||||
badgeHtml = `<span style="font-size:12px;color:#ff8b00;font-weight:bold;">📉 -${100 - p_vsTeam}% del prom.</span>`;
|
||||
} else {
|
||||
badgeHtml = `<span style="font-size:12px;color:#5e6c84;">~ ${p_vsTeam}% del prom.</span>`;
|
||||
}
|
||||
|
||||
tbody.innerHTML += `<tr>
|
||||
<td><div class="name-cell"><div class="avatar" style="background:${c}">${initials(p)}</div>${p}</div></td>
|
||||
<td><span class="pill done">${sp} SP entregados</span></td>
|
||||
<td><strong>${tickets} tickets</strong></td>
|
||||
<td><div style="display:flex;align-items:center;gap:8px;">
|
||||
<div style="background:#f0f2f5;border-radius:4px;height:8px;width:120px;overflow:hidden;">
|
||||
<div style="background:#36b37e;height:100%;width:${p_}%"></div>
|
||||
</div><span style="font-size:12px;color:#5e6c84;">${p_}%</span></div></td>
|
||||
<td><span class="pill done">${sp} SP</span></td>
|
||||
<td><strong>${tickets} tkts</strong></td>
|
||||
<td><span style="font-size:13px;color:#172b4d;">${avgPerTicket} SP/tkt</span></td>
|
||||
<td>${badgeHtml}</td>
|
||||
</tr>`;
|
||||
});
|
||||
}
|
||||
@@ -369,7 +398,7 @@ function renderQASection() {
|
||||
|
||||
const pctRet = qa.tickets > 0 ? ((qa.ticketsReturned / qa.tickets) * 100).toFixed(1) : 0;
|
||||
document.getElementById('kpi-qa-returned-pct').textContent =
|
||||
`${pctRet}% del total pasó por QA más de una vez`;
|
||||
`${pctRet}% del total sufrió al menos 1 rechazo`;
|
||||
|
||||
// ── GRÁFICO 1: Distribución de tiempos en QA (doughnut) ───────────────────
|
||||
const buckets = { '<30min': 0, '30min–2hs': 0, '2–8hs': 0, '>8hs': 0 };
|
||||
@@ -391,7 +420,7 @@ function renderQASection() {
|
||||
const aprobados = qa.tickets - qa.ticketsReturned;
|
||||
chartsManager.drawDoughnutChart(
|
||||
'qaReturned', 'chartQaReturned',
|
||||
['✅ Aprobados directo', '🔄 Devueltos (>1 vez en curso)'],
|
||||
['✅ Flujo directo (0 rechazos)', '🔄 Retrabajo (Rechazo en CR/QA)'],
|
||||
[aprobados, qa.ticketsReturned],
|
||||
['#36b37e', '#ff5630']
|
||||
);
|
||||
@@ -458,8 +487,9 @@ function renderQASection() {
|
||||
tbody.innerHTML += `<tr class="${isCritical ? 'row-critical' : ''}">
|
||||
<td><a href="${jiraUrl}" target="_blank" class="jira-link">${i.key}</a></td>
|
||||
<td style="max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${i.summary}">${i.summary}</td>
|
||||
<td><strong style="color:${isCritical ? '#ff5630' : '#172b4d'}">${i.timeQa}hs</strong>${isCritical ? ' ⚠️' : ''}</td>
|
||||
<td>${i.timeRtqa > 0 ? i.timeRtqa + 'hs' : '—'}</td>
|
||||
<td style="color:#0065ff;">${i.timeDev > 0 ? i.timeDev + 'hs' : '—'}</td>
|
||||
<td style="color:#00b8d9;">${i.timeCr > 0 ? i.timeCr + 'hs' : '—'}</td>
|
||||
<td><strong style="color:${isCritical ? '#ff5630' : '#36b37e'}">${i.timeQa}hs</strong>${isCritical ? ' ⚠️' : ''}</td>
|
||||
<td>${returnBadge}</td>
|
||||
<td>${statusLabel}</td>
|
||||
</tr>`;
|
||||
|
||||
@@ -248,9 +248,19 @@ function processIssues(issues, period = '90d') {
|
||||
|
||||
const timeQaMin = f[config.fields.qaTotalTimeQa] ?? 0;
|
||||
const timeRtqaMin = f[config.fields.qaTotalTimeRtqa] ?? 0;
|
||||
const timesRet = f[config.fields.timesReturned] ?? 0;
|
||||
let timesRet = f[config.fields.timesReturned] ?? 0;
|
||||
|
||||
// Ignorar valores erróneos acumulados por la automatización vieja
|
||||
const automationFixDate = new Date('2026-04-07T00:00:00-03:00');
|
||||
const issueCreated = f.created ? new Date(f.created) : null;
|
||||
if (issueCreated && issueCreated < automationFixDate) {
|
||||
timesRet = 1; // Lo forzamos a 1 (no devuelto) para que no rompa las métricas con data sucia
|
||||
}
|
||||
const qaMonth = resMonth || creMonth;
|
||||
|
||||
const timeCrMin = f[config.fields.crTotalTime] ?? 0;
|
||||
const timeDevMin = f[config.fields.enCursoTotalTime] ?? 0;
|
||||
|
||||
if (timeQaMin > 0) {
|
||||
qaData.tickets++;
|
||||
qaData.totalTimeQa += timeQaMin;
|
||||
@@ -262,7 +272,8 @@ function processIssues(issues, period = '90d') {
|
||||
}
|
||||
qaData.issues.push({
|
||||
key: issue.key, summary: f.summary, timeQa: minsToHrs(timeQaMin), timeRtqa: minsToHrs(timeRtqaMin),
|
||||
timesReturned: timesRet, status: statusKey, resMonth: qaMonth
|
||||
timesReturned: timesRet, status: statusKey, resMonth: qaMonth,
|
||||
timeCr: minsToHrs(timeCrMin), timeDev: minsToHrs(timeDevMin)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -275,7 +286,6 @@ function processIssues(issues, period = '90d') {
|
||||
}
|
||||
}
|
||||
|
||||
const timeCrMin = f[config.fields.crTotalTime] ?? 0;
|
||||
if (timeCrMin > 0) {
|
||||
crData.tickets++;
|
||||
crData.totalTimeCr += timeCrMin;
|
||||
|
||||
Reference in New Issue
Block a user