Files
dashboard-infra/js/app.js
T
2026-04-04 00:49:55 -03:00

448 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* js/app.js
* Controlador principal de la UI. Lee desde api.js y actualiza el DOM + charts.js
*/
import { api } from './api.js';
import { chartsManager } from './charts.js';
// utils
function shortName(name) { const p = name.split(' '); return p[0] + (p[1] ? ' ' + p[1] : ''); }
function initials(name) { return name.split(' ').map(w=>w[0]).join('').slice(0,2).toUpperCase(); }
function pct(val, total) { return total > 0 ? (val / total * 100).toFixed(1) : 0; }
const state = {
selectedDev: 'all',
selectedPeriod: '90d'
};
// ── INIT ───────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
// Load from localStorage
const savedDev = localStorage.getItem('dash_selectedDev');
const savedPeriod = localStorage.getItem('dash_selectedPeriod');
if (savedDev) state.selectedDev = savedDev;
if (savedPeriod) state.selectedPeriod = savedPeriod;
document.getElementById('refreshBtn').addEventListener('click', () => loadAndRender(true));
document.getElementById('devFilter').addEventListener('change', (e) => {
state.selectedDev = e.target.value;
localStorage.setItem('dash_selectedDev', state.selectedDev);
renderCurrentSection();
renderHistorySection();
});
document.getElementById('timeRangeFilter').addEventListener('change', (e) => {
state.selectedPeriod = e.target.value;
localStorage.setItem('dash_selectedPeriod', state.selectedPeriod);
loadAndRender(false);
});
// Set initial value in UI from state (which might come from localStorage)
const periodFilter = document.getElementById('timeRangeFilter');
if (periodFilter) periodFilter.value = state.selectedPeriod;
loadAndRender();
});
async function loadAndRender(forceRefresh = false) {
const loading = document.getElementById('loadingOverlay');
const errorBanner = document.getElementById('errorBanner');
loading.style.display = 'flex';
errorBanner.style.display = 'none';
document.getElementById('refreshBtn').disabled = true;
try {
const data = await api.fetchDashboardData(forceRefresh, state.selectedPeriod);
const limitBanner = document.getElementById('limitBanner');
if (data.isTruncated) {
limitBanner.style.display = 'block';
limitBanner.textContent = `⚠️ Advertencia de límite: Solo se procesaron 1500 tickets en este período debido a las políticas de seguridad (Total real encontrado: ${data.jiraTotal}). Reducí el rango de fechas para ver el detalle exacto.`;
} else {
limitBanner.style.display = 'none';
}
buildFilters(data);
renderHeader(data);
renderHealthWarnings(data.warnings);
renderCurrentSection();
renderHistorySection();
renderCRSection();
renderQASection();
} catch(e) {
errorBanner.style.display = 'block';
errorBanner.textContent = '⚠️ Error al cargar datos: ' + e.message;
} finally {
loading.style.display = 'none';
document.getElementById('refreshBtn').disabled = false;
}
}
// ── FILTERS & HEADER ───────────────────────────────────────────────────────
function buildFilters(data) {
const devsSet = new Set();
data.issuesList.forEach(issue => {
issue.devs.forEach(d => devsSet.add(d));
});
const devSel = document.getElementById('devFilter');
devSel.innerHTML = '<option value="all">Todos los desarrolladores</option>'
+ [...devsSet].sort().map(d => `<option value="${d}">${d}</option>`).join('');
devSel.value = state.selectedDev;
}
function renderHeader(data) {
const d = new Date(data.lastUpdated);
document.getElementById('lastUpdatedLabel').textContent =
'Actualizado: ' + d.toLocaleString('es-AR', { day:'2-digit', month:'2-digit', year:'numeric', hour:'2-digit', minute:'2-digit' });
document.getElementById('cacheBadge').textContent = data.fromCache ? '(desde caché)' : '(CACHE)';
}
function getJiraUrl(key) {
const host = api.data?.config?.host || 'invera.atlassian.net';
return `https://${host}/browse/${key}`;
}
// ── HEALTH WARNINGS ────────────────────────────────────────────────────────
function renderHealthWarnings(warnings = []) {
const container = document.getElementById('warningsSection');
const list = document.getElementById('warningsList');
const noMsg = document.getElementById('noWarningsMsg');
if(!warnings || warnings.length === 0) {
container.style.display = 'none';
if (noMsg) noMsg.style.display = 'block';
return;
}
container.style.display = 'block';
if (noMsg) noMsg.style.display = 'none';
list.innerHTML = warnings.map(w => {
let text = '';
let badge = w.type;
const link = w.key ? `<a href="${getJiraUrl(w.key)}" target="_blank" class="jira-link">${w.key}</a>` : '';
if(w.type === 'stalled') { badge = 'ESTANCADO'; text = `🎟️ [${link}] ${w.summary.slice(0,50)}... (${w.devs.join(', ')})`; }
if(w.type === 'done_no_sp') { badge = 'SIN SP'; text = `🎟️ [${link}] ${w.summary.slice(0,50)}... (Completado sin estimación)`; }
if(w.type === 'unassigned_sp') { badge = 'HUÉRFANO'; text = `🎟️ [${link}] ${w.summary.slice(0,50)}... (${w.sp} SP sin dueño)`; }
if(w.type === 'free_dev') { badge = 'DEV LIBRE'; text = `👤 ${w.summary}`; }
return `<li class="warning-item" title="${w.summary}"><span class="badge" style="width:90px;text-align:center;">${badge}</span> <span class="title" style="font-size:11px;">${text}</span></li>`;
}).join('');
}
// ── CURRENT SECTION (Interactive filtered) ─────────────────────────────────
function renderCurrentSection() {
const data = api.data;
if (!data) return;
// 1. Filtrar solo pendientes (En progreso o To Do) + Filtro de Dev
let allPending = data.issuesList.filter(i => i.status !== 'done');
// Para el label "Mostrando X de Y", filtramos por Dev
let filtered = allPending;
if (state.selectedDev !== 'all') {
filtered = allPending.filter(i => i.devs.includes(state.selectedDev));
}
// Agrupar métricas para los KPIs (basado en el filtro de Dev)
const ad = {};
let totalDevs = new Set();
filtered.forEach(i => {
i.devs.forEach(dev => {
if(state.selectedDev !== 'all' && dev !== state.selectedDev) return;
if (!ad[dev]) ad[dev] = { in_progress: 0, todo: 0, issues_ip: [], issues_todo: [] };
if (i.status === 'indeterminate') {
ad[dev].in_progress += i.sp;
ad[dev].issues_ip.push(i);
} else {
ad[dev].todo += i.sp;
ad[dev].issues_todo.push(i);
}
totalDevs.add(dev);
});
});
let ip = 0, todo = 0;
Object.values(ad).forEach(d => { ip += d.in_progress; todo += d.todo; });
document.getElementById('kpi-ip').textContent = ip;
document.getElementById('kpi-todo').textContent = todo;
document.getElementById('kpi-members').textContent = totalDevs.size;
// Conteo de tickets
const label = document.getElementById('currentTicketsLabel');
if (label) {
label.textContent = state.selectedDev === 'all'
? `Hay ${allPending.length} tickets pendientes en total`
: `Mostrando ${filtered.length} tickets pendientes para ${state.selectedDev}`;
}
// Gráfico: Comparativa IP vs Todo (Stacked)
const people = Object.keys(ad).sort((a,b) => (ad[b].in_progress+ad[b].todo)-(ad[a].in_progress+ad[a].todo));
chartsManager.drawBarChart('currentState', 'chartCurrentState', people.map(shortName), [
{ label:'En Progreso', data:people.map(p=>ad[p].in_progress), backgroundColor:'#0065ff', borderRadius:3 },
{ label:'Por Hacer', data:people.map(p=>ad[p].todo), backgroundColor:'#ff8b00', borderRadius:3 },
], { plugins:{ legend:{ position:'bottom'} }, scales:{ x:{stacked:true,grid:{display:false}}, y:{stacked:true} } });
// Tabla detallada (Solo pendientes)
const tbody = document.getElementById('tableCurrentState');
tbody.innerHTML = '';
people.forEach(p => {
const d = ad[p], total = d.in_progress + d.todo;
const c = chartsManager.personColor(p);
const popoverIP = d.issues_ip.map(i => `<div class="pop-item"><a href="${getJiraUrl(i.key)}" target="_blank">${i.key}</a>: ${i.summary.slice(0,30)}... <strong>(${i.sp} SP)</strong></div>`).join('');
const popoverTodo = d.issues_todo.map(i => `<div class="pop-item"><a href="${getJiraUrl(i.key)}" target="_blank">${i.key}</a>: ${i.summary.slice(0,30)}... <strong>(${i.sp} SP)</strong></div>`).join('');
tbody.innerHTML += `<tr>
<td>
<div class="name-cell"><div class="avatar" style="background:${c}">${initials(p)}</div>${p}</div>
</td>
<td>
<div class="pop-trigger">
<span class="pill progress">${d.in_progress} SP</span>
<div class="pop-content">${popoverIP || 'No hay tickets'}</div>
</div>
</td>
<td>
<div class="pop-trigger">
<span class="pill todo">${d.todo} SP</span>
<div class="pop-content">${popoverTodo || 'No hay tickets'}</div>
</div>
</td>
<td><strong>${total} SP</strong></td>
<td><div class="pbar-wrap">
<div class="pbar-ip" style="width:${pct(d.in_progress, total)}%"></div>
<div class="pbar-todo" style="width:${pct(d.todo, total)}%"></div>
</div></td>
</tr>`;
});
}
// ── HISTORY SECTION ────────────────────────────────────────────────────────
function renderHistorySection() {
const data = api.data;
if(!data) return;
// Consolidar todos los meses del rango actual
let periodData = {};
let periodTicketCount = {};
data.issuesList.filter(i => i.status === 'done').forEach(i => {
i.devs.forEach(p => {
if (state.selectedDev !== 'all' && p !== state.selectedDev) return;
periodData[p] = (periodData[p] || 0) + i.sp;
periodTicketCount[p] = (periodTicketCount[p] || 0) + 1;
});
});
const totalSP = Object.values(periodData).reduce((a,b) => a+b, 0);
const devCount = Object.keys(periodData).filter(p => periodData[p] > 0).length;
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;
// 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
}], { plugins:{ legend:{ display:false } }, scales:{ x:{grid:{display:false}} } });
// Tabla de Resultados
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>';
} else {
sorted.forEach(([p, sp]) => {
const p_ = totalSP > 0 ? (sp / totalSP * 100).toFixed(1) : 0;
const c = chartsManager.personColor(p);
const tickets = periodTicketCount[p] || 0;
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>
</tr>`;
});
}
// Delivery KPIs
if (data.deliveryData) {
const { avgLeadTime, avgCycleTime, spOriginal, spFinal, spDeviationPct } = data.deliveryData;
document.getElementById('kpi-lead-time').textContent = avgLeadTime;
document.getElementById('kpi-cycle-time').textContent = avgCycleTime;
const deviationPrefix = spDeviationPct > 0 ? '+' : '';
document.getElementById('kpi-sp-deviation').textContent = `${deviationPrefix}${spDeviationPct}%`;
document.getElementById('kpi-sp-deviation-sub').textContent = `${spOriginal} orig. → ${spFinal} fin.`;
}
// Gráfico de Velocidad Mensual
const months = Object.keys(data.monthlyDone).sort();
const allDevsInHistory = [...new Set(months.flatMap(m => Object.keys(data.monthlyDone[m])))];
// Si hay filtro de dev, solo mostrar ese dev en el gráfico de velocidad
const visibleDevs = state.selectedDev === 'all' ? allDevsInHistory : [state.selectedDev];
chartsManager.drawBarChart('velocity', 'chartVelocity', months, visibleDevs.map(p => ({
label: shortName(p),
data: months.map(m => data.monthlyDone[m][p] || 0),
backgroundColor: chartsManager.personColor(p), borderRadius: 3
})), { plugins:{ legend:{ position:'bottom'} }, scales:{ x:{stacked:true,grid:{display:false}}, y:{stacked:true} } });
}
// ── CODE REVIEW SECTION ────────────────────────────────────────────────────
function renderCRSection() {
const data = api.data;
if (!data || !data.crData) return;
const cr = data.crData;
document.getElementById('kpi-cr-tickets').textContent = cr.tickets ?? '—';
document.getElementById('kpi-cr-avg').textContent = cr.avgTimeCr != null ? cr.avgTimeCr + 'hs' : '—';
document.getElementById('kpi-cr-stalled').textContent = (cr.stalledIssues?.length) || '0';
const crMonths = Object.keys(cr.monthlyAvgCr || {}).sort();
chartsManager.drawLineChart(
'crMonthly', 'chartCrMonthly',
crMonths,
[{
label: 'Prom. tiempo en CR (hs)',
data: crMonths.map(m => cr.monthlyAvgCr[m] || 0),
borderColor: '#00b8d9',
backgroundColor: 'rgba(0,184,217,.12)',
fill: true,
pointBackgroundColor: '#00b8d9',
borderWidth: 2,
}],
{ plugins:{ legend:{ display:false } }, scales:{ x:{grid:{display:false}}, y:{ beginAtZero:true } } }
);
}
// ── QA SECTION ─────────────────────────────────────────────────────────────
function renderQASection() {
const data = api.data;
if (!data || !data.qaData) return;
const qa = data.qaData;
// ── KPIs ──────────────────────────────────────────────────────────────────
document.getElementById('kpi-qa-tickets').textContent = qa.tickets ?? '—';
document.getElementById('kpi-qa-avg').textContent = qa.avgTimeQa != null ? qa.avgTimeQa + 'hs' : '—';
document.getElementById('kpi-qa-rtqa').textContent = qa.avgTimeRtqa != null ? qa.avgTimeRtqa + 'hs' : '—';
document.getElementById('kpi-qa-returned').textContent = qa.ticketsReturned ?? '—';
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`;
// ── GRÁFICO 1: Distribución de tiempos en QA (doughnut) ───────────────────
const buckets = { '<30min': 0, '30min–2hs': 0, '2–8hs': 0, '>8hs': 0 };
(qa.issues || []).forEach(i => {
const hs = i.timeQa;
if (hs < 0.5) buckets['<30min']++;
else if (hs < 2) buckets['30min–2hs']++;
else if (hs < 8) buckets['2–8hs']++;
else buckets['>8hs']++;
});
chartsManager.drawDoughnutChart(
'qaDist', 'chartQaDist',
Object.keys(buckets),
Object.values(buckets),
['#36b37e', '#00b8d9', '#ff8b00', '#ff5630']
);
// ── GRÁFICO 2: Devueltos vs Aprobados (doughnut) ──────────────────────────
const aprobados = qa.tickets - qa.ticketsReturned;
chartsManager.drawDoughnutChart(
'qaReturned', 'chartQaReturned',
['✅ Aprobados directo', '🔄 Devueltos (>1 vez en curso)'],
[aprobados, qa.ticketsReturned],
['#36b37e', '#ff5630']
);
// ── GRÁFICO 3: Tiempo promedio QA mes a mes (line) ────────────────────────
const qaMonths = Object.keys(qa.monthlyAvgQa || {}).sort();
chartsManager.drawLineChart(
'qaMonthly', 'chartQaMonthly',
qaMonths,
[{
label: 'Prom. tiempo en QA (hs)',
data: qaMonths.map(m => qa.monthlyAvgQa[m] || 0),
borderColor: '#6554c0',
backgroundColor: 'rgba(101,84,192,.12)',
fill: true,
pointBackgroundColor: '#6554c0',
borderWidth: 2,
}],
{ plugins:{ legend:{ display:false } }, scales:{ x:{grid:{display:false}}, y:{ beginAtZero:true } } }
);
// ── GRÁFICO 4: Tiempo en cola RTQA mes a mes (bar) ────────────────────────
const rtqaMonths = Object.keys(qa.monthlyAvgRtqa || {}).sort();
chartsManager.drawBarChart(
'rtqaMonthly', 'chartRtqaMonthly',
rtqaMonths,
[{
label: 'Prom. tiempo en cola RTQA (hs)',
data: rtqaMonths.map(m => qa.monthlyAvgRtqa[m] || 0),
backgroundColor: '#00b8d9',
borderRadius: 4,
}],
{ plugins:{ legend:{ display:false } }, scales:{ x:{grid:{display:false}}, y:{ beginAtZero:true } } }
);
// ── TABLA de tickets QA ───────────────────────────────────────────────────
const host = api.data?.config?.host || 'invera.atlassian.net';
const tbody = document.getElementById('tableQA');
tbody.innerHTML = '';
const sorted = [...(qa.issues || [])].sort((a, b) => b.timeQa - a.timeQa);
if (sorted.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="no-data">No hay tickets con datos de QA en este período</td></tr>';
return;
}
const top5 = sorted.slice(0, 5);
top5.forEach(i => {
const isCritical = i.timeQa > 4;
const isReturned = i.timesReturned > 1;
const jiraUrl = `https://${host}/browse/${i.key}`;
const statusLabel = i.status === 'done'
? '<span class="pill done">✅ Done</span>'
: i.status === 'indeterminate'
? '<span class="pill progress">🔵 En curso</span>'
: '<span class="pill todo">⏳ Pendiente</span>';
const returnBadge = isReturned
? `<span class="pill returned">🔄 ${i.timesReturned}x</span>`
: `<span class="pill qa">1x</span>`;
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>${returnBadge}</td>
<td>${statusLabel}</td>
</tr>`;
});
}