695401f237
- Reemplaza Netlify Functions con servidor Python/Flask (server.py) - Adapta dashboard para contar tickets en lugar de Story Points - Conecta al proyecto IO (Infra) de Jira - Elimina secciones de QA y Code Review - Agrega métricas de solicitudes semanales y reporters - Excluye devs inactivos y unifica nombres duplicados Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
358 lines
16 KiB
JavaScript
358 lines
16 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();
|
|
|
|
} 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 === 'expired') { badge = 'VENCIDO'; text = `🗓️ [${link}] ${w.summary.slice(0,50)}... (Vencido hace ${w.days} días)`; }
|
|
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)
|
|
// "Sin asignar" siempre va último y con color gris
|
|
const people = Object.keys(ad).sort((a, b) => {
|
|
if (a === 'Sin asignar') return 1;
|
|
if (b === 'Sin asignar') return -1;
|
|
return (ad[b].in_progress + ad[b].todo) - (ad[a].in_progress + ad[a].todo);
|
|
});
|
|
const colorIP = people.map(p => p === 'Sin asignar' ? '#97a0af' : '#0065ff');
|
|
const colorTodo = people.map(p => p === 'Sin asignar' ? '#c1c7d0' : '#ff8b00');
|
|
chartsManager.drawBarChart('currentState', 'chartCurrentState', people.map(shortName), [
|
|
{ label:'En Progreso', data:people.map(p=>ad[p].in_progress), backgroundColor:colorIP, borderRadius:3 },
|
|
{ label:'Por Hacer', data:people.map(p=>ad[p].todo), backgroundColor:colorTodo, 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 = p === 'Sin asignar' ? '#97a0af' : chartsManager.personColor(p);
|
|
|
|
const hasIPAlert = d.issues_ip.some(i => i.warnings && i.warnings.some(w => w.type === 'expired' || w.type === 'stalled'));
|
|
const hasTodoAlert = d.issues_todo.some(i => i.warnings && i.warnings.some(w => w.type === 'expired' || w.type === 'stalled'));
|
|
|
|
const genPopover = (issues) => issues.map(i => {
|
|
let badge = '';
|
|
if(i.warnings && i.warnings.some(w => w.type === 'expired')) badge = '<span style="color:#ff5630;font-size:11px;font-weight:bold;margin-left:4px;">(Vencido)</span>';
|
|
else if(i.warnings && i.warnings.some(w => w.type === 'stalled')) badge = '<span style="color:#ffab00;font-size:11px;font-weight:bold;margin-left:4px;">(Estancado)</span>';
|
|
return `<div class="pop-item" style="display:flex;align-items:center;white-space:nowrap;"><a href="${getJiraUrl(i.key)}" target="_blank" style="margin-right:6px;">${i.key}</a> <span style="overflow:hidden;text-overflow:ellipsis;max-width:180px;" title="${i.summary.replace(/"/g, '"')}">${i.summary}</span> <strong style="margin-left:auto;padding-left:8px;">(${i.sp} SP)</strong>${badge}</div>`;
|
|
}).join('');
|
|
|
|
const popoverIP = genPopover(d.issues_ip);
|
|
const popoverTodo = genPopover(d.issues_todo);
|
|
|
|
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" ${hasIPAlert ? 'style="border-color:#ff5630;color:#ff5630;"' : ''}>${d.in_progress} tkts${hasIPAlert ? ' ⚠️' : ''}</span>
|
|
<div class="pop-content">${popoverIP || 'No hay tickets'}</div>
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<div class="pop-trigger">
|
|
<span class="pill todo" ${hasTodoAlert ? 'style="border-color:#ff5630;color:#ff5630;"' : ''}>${d.todo} tkts${hasTodoAlert ? ' ⚠️' : ''}</span>
|
|
<div class="pop-content">${popoverTodo || 'No hay tickets'}</div>
|
|
</div>
|
|
</td>
|
|
<td><strong>${total} tkts</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 = {};
|
|
|
|
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;
|
|
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 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;
|
|
|
|
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';
|
|
}
|
|
|
|
// Calcular semanas del período seleccionado
|
|
const periodDays = state.selectedPeriod === 'all' ? 365
|
|
: state.selectedPeriod === '1d' ? 1
|
|
: parseInt(state.selectedPeriod.replace('d', ''));
|
|
const periodWeeks = Math.max(periodDays / 7, 1);
|
|
const totalTickets = Object.values(periodTicketCount).reduce((a,b) => a+b, 0);
|
|
const avgPerWeek = (totalTickets / periodWeeks).toFixed(1);
|
|
|
|
document.getElementById('kpi-done-period').textContent = totalSP;
|
|
document.getElementById('kpi-avg-period').textContent = avgPerWeek;
|
|
|
|
// Tickets solicitados (creados) por semana
|
|
const ticketsCreated = data.ticketsCreated || 0;
|
|
const avgCreatedPerWeek = (ticketsCreated / periodWeeks).toFixed(1);
|
|
document.getElementById('kpi-created-week').textContent = avgCreatedPerWeek;
|
|
const createdSubEl = document.getElementById('kpi-created-week-sub');
|
|
if (createdSubEl) createdSubEl.textContent = `${ticketsCreated} tkts en el período`;
|
|
|
|
// 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 (sortedAll.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="5" class="no-data">No hay entregas en este período</td></tr>';
|
|
} else {
|
|
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">${tickets} tkts</span></td>
|
|
<td>${badgeHtml}</td>
|
|
</tr>`;
|
|
});
|
|
}
|
|
|
|
|
|
// Gráfico de Solicitudes por persona (reporter)
|
|
const reporterData = data.reporterData || {};
|
|
const sortedReporters = Object.entries(reporterData).sort((a, b) => b[1] - a[1]);
|
|
chartsManager.drawBarChart('velocity', 'chartVelocity',
|
|
sortedReporters.map(([p]) => shortName(p)),
|
|
[{
|
|
label: 'Tickets solicitados',
|
|
data: sortedReporters.map(([, v]) => v),
|
|
backgroundColor: sortedReporters.map(([p]) => chartsManager.personColor(p)),
|
|
borderRadius: 5
|
|
}],
|
|
{ plugins:{ legend:{ display:false } }, scales:{ x:{ grid:{ display:false } } } }
|
|
);
|
|
}
|
|
|
|
|