407 lines
18 KiB
JavaScript
407 lines
18 KiB
JavaScript
/**
|
||
* 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();
|
||
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>`;
|
||
});
|
||
}
|
||
|
||
// 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} } });
|
||
}
|
||
|
||
// ── 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;
|
||
}
|
||
|
||
sorted.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>`;
|
||
});
|
||
}
|