tercer commit
This commit is contained in:
@@ -73,6 +73,15 @@ export const config = {
|
||||
spEstimate: getEnv('JIRA_FIELD_SP_ESTIMATE', 'customfield_10016'),
|
||||
spFinal: getEnv('JIRA_FIELD_SP_FINAL', 'customfield_11019'),
|
||||
developer: getEnv('JIRA_FIELD_DEVELOPER', 'customfield_10622'),
|
||||
// ── QA Fields ────────────────────────────────────────────────────
|
||||
qaAssignee: 'customfield_10632',
|
||||
qaEntryRtqa: 'customfield_10821',
|
||||
qaTotalTimeRtqa: 'customfield_10822',
|
||||
qaLastDurRtqa: 'customfield_10823',
|
||||
qaEntryQa: 'customfield_10824',
|
||||
qaTotalTimeQa: 'customfield_10825',
|
||||
qaLastDurQa: 'customfield_10826',
|
||||
timesReturned: 'customfield_11217',
|
||||
},
|
||||
|
||||
// ── JQL Query ──────────────────────────────────────────────────────
|
||||
|
||||
+86
@@ -103,6 +103,15 @@ function buildFieldsList() {
|
||||
'resolutiondate',
|
||||
'created',
|
||||
'updated',
|
||||
// ── QA Fields ──────────────────────────────────────
|
||||
config.fields.qaAssignee, // QA asignado
|
||||
config.fields.qaEntryRtqa, // Fecha entrada READY TO QA
|
||||
config.fields.qaTotalTimeRtqa, // Tiempo total en READY TO QA (min)
|
||||
config.fields.qaLastDurRtqa, // Última duración en READY TO QA (min)
|
||||
config.fields.qaEntryQa, // Fecha entrada QA
|
||||
config.fields.qaTotalTimeQa, // Tiempo total en QA (min)
|
||||
config.fields.qaLastDurQa, // Última duración en QA (min)
|
||||
config.fields.timesReturned, // Veces que volvió a En Curso
|
||||
].join(',');
|
||||
}
|
||||
|
||||
@@ -112,6 +121,12 @@ function buildFieldsList() {
|
||||
* @param {Array} issues - Array de issues de JIRA
|
||||
* @returns {Object} Datos procesados
|
||||
*/
|
||||
// Helper: convierte minutos a horas redondeadas a 1 decimal
|
||||
function minsToHrs(mins) {
|
||||
if (mins == null || mins === 0) return 0;
|
||||
return Math.round((mins / 60) * 10) / 10;
|
||||
}
|
||||
|
||||
function processIssues(issues) {
|
||||
const assigneeData = {};
|
||||
const monthlyDone = {};
|
||||
@@ -119,6 +134,19 @@ function processIssues(issues) {
|
||||
const warnings = [];
|
||||
const now = new Date();
|
||||
|
||||
// ── QA Aggregation ──────────────────────────────────────────────────
|
||||
const qaData = {
|
||||
tickets: 0,
|
||||
ticketsReturned: 0,
|
||||
totalTimeQa: 0, // minutos
|
||||
totalTimeRtqa: 0, // minutos
|
||||
monthlyTotalQa: {}, // { 'YYYY-MM': totalMin }
|
||||
monthlyCountQa: {}, // { 'YYYY-MM': count }
|
||||
monthlyTotalRtqa: {},
|
||||
monthlyCountRtqa: {},
|
||||
issues: [],
|
||||
};
|
||||
|
||||
let processedCount = 0;
|
||||
let skippedCount = 0;
|
||||
|
||||
@@ -197,6 +225,41 @@ function processIssues(issues) {
|
||||
creMonth
|
||||
});
|
||||
|
||||
// ── Acumular datos de QA ────────────────────────────────────
|
||||
const timeQaMin = f[config.fields.qaTotalTimeQa] ?? 0;
|
||||
const timeRtqaMin = f[config.fields.qaTotalTimeRtqa] ?? 0;
|
||||
const lastDurQa = f[config.fields.qaLastDurQa] ?? 0;
|
||||
const timesRet = f[config.fields.timesReturned] ?? 0;
|
||||
const qaMonth = resMonth || creMonth;
|
||||
|
||||
if (timeQaMin > 0) {
|
||||
qaData.tickets++;
|
||||
qaData.totalTimeQa += timeQaMin;
|
||||
if (timesRet > 1) qaData.ticketsReturned++;
|
||||
|
||||
if (qaMonth) {
|
||||
qaData.monthlyTotalQa[qaMonth] = (qaData.monthlyTotalQa[qaMonth] || 0) + timeQaMin;
|
||||
qaData.monthlyCountQa[qaMonth] = (qaData.monthlyCountQa[qaMonth] || 0) + 1;
|
||||
}
|
||||
|
||||
qaData.issues.push({
|
||||
key: issue.key,
|
||||
summary: f.summary,
|
||||
timeQa: minsToHrs(timeQaMin),
|
||||
timeRtqa: minsToHrs(timeRtqaMin),
|
||||
lastDurQa: minsToHrs(lastDurQa),
|
||||
timesReturned: timesRet,
|
||||
status: statusKey,
|
||||
resMonth: qaMonth,
|
||||
});
|
||||
}
|
||||
|
||||
if (timeRtqaMin > 0 && qaMonth) {
|
||||
qaData.totalTimeRtqa += timeRtqaMin;
|
||||
qaData.monthlyTotalRtqa[qaMonth] = (qaData.monthlyTotalRtqa[qaMonth] || 0) + timeRtqaMin;
|
||||
qaData.monthlyCountRtqa[qaMonth] = (qaData.monthlyCountRtqa[qaMonth] || 0) + 1;
|
||||
}
|
||||
|
||||
// ── Detectar Warnings ───────────────────────────────────────
|
||||
// 1. Tickets > 14 días in-progress
|
||||
if (isInProgress && f.updated) {
|
||||
@@ -225,6 +288,28 @@ function processIssues(issues) {
|
||||
|
||||
console.log(`📊 Procesados: ${processedCount} | Saltados: ${skippedCount} | Warnings: ${warnings.length}`);
|
||||
|
||||
// ── Calcular promedios mensuales QA ─────────────────────────────────
|
||||
const monthlyAvgQa = {};
|
||||
for (const m of Object.keys(qaData.monthlyTotalQa)) {
|
||||
monthlyAvgQa[m] = minsToHrs(qaData.monthlyTotalQa[m] / qaData.monthlyCountQa[m]);
|
||||
}
|
||||
const monthlyAvgRtqa = {};
|
||||
for (const m of Object.keys(qaData.monthlyTotalRtqa)) {
|
||||
monthlyAvgRtqa[m] = minsToHrs(qaData.monthlyTotalRtqa[m] / qaData.monthlyCountRtqa[m]);
|
||||
}
|
||||
qaData.monthlyAvgQa = monthlyAvgQa;
|
||||
qaData.monthlyAvgRtqa = monthlyAvgRtqa;
|
||||
qaData.avgTimeQa = qaData.tickets > 0 ? minsToHrs(qaData.totalTimeQa / qaData.tickets) : 0;
|
||||
qaData.avgTimeRtqa = qaData.tickets > 0 ? minsToHrs(qaData.totalTimeRtqa / qaData.tickets) : 0;
|
||||
|
||||
// Limpiar campos internos de acumulación
|
||||
delete qaData.monthlyTotalQa;
|
||||
delete qaData.monthlyCountQa;
|
||||
delete qaData.monthlyTotalRtqa;
|
||||
delete qaData.monthlyCountRtqa;
|
||||
|
||||
console.log(`🧪 QA: ${qaData.tickets} tickets | ${qaData.ticketsReturned} devueltos | Avg QA: ${qaData.avgTimeQa}hs`);
|
||||
|
||||
return {
|
||||
lastUpdated: new Date().toISOString(),
|
||||
totalIssues: processedCount,
|
||||
@@ -233,6 +318,7 @@ function processIssues(issues) {
|
||||
monthlyDone,
|
||||
issuesList,
|
||||
warnings,
|
||||
qaData,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+44
-9
@@ -51,6 +51,10 @@ select:focus { border-color: #0052cc; }
|
||||
.kpi.todo { border-color: #ff8b00; }
|
||||
.kpi.members { border-color: #6554c0; }
|
||||
.kpi.warning { border-color: #ff5630; }
|
||||
.kpi.qa { border-color: #6554c0; }
|
||||
.kpi.qa-time { border-color: #00b8d9; }
|
||||
.kpi.qa-queue { border-color: #36b37e; }
|
||||
.kpi.returned { border-color: #ff5630; }
|
||||
|
||||
.kpi-value { font-size: 36px; font-weight: 700; line-height: 1; }
|
||||
.kpi.done .kpi-value { color: #36b37e; }
|
||||
@@ -58,11 +62,23 @@ select:focus { border-color: #0052cc; }
|
||||
.kpi.todo .kpi-value { color: #ff8b00; }
|
||||
.kpi.members .kpi-value { color: #6554c0; }
|
||||
.kpi.warning .kpi-value { color: #ff5630; }
|
||||
.kpi.qa .kpi-value { color: #6554c0; }
|
||||
.kpi.qa-time .kpi-value { color: #00b8d9; }
|
||||
.kpi.qa-queue .kpi-value { color: #36b37e; }
|
||||
.kpi.returned .kpi-value { color: #ff5630; }
|
||||
|
||||
.kpi-label { font-size: 12px; color: #5e6c84; margin-top: 6px; font-weight: 500; text-transform: uppercase; letter-spacing: .5px; }
|
||||
.kpi-sub { font-size: 11px; color: #97a0af; margin-top: 4px; }
|
||||
|
||||
/* Charts */
|
||||
/* Section header QA */
|
||||
.section-header.qa { border-color: #6554c0; }
|
||||
.section-header.qa .dot { background: #6554c0; }
|
||||
|
||||
/* Pill y row QA */
|
||||
.pill.qa { background: #f3f0ff; color: #403294; }
|
||||
.pill.returned { background: #ffebe6; color: #bf2600; }
|
||||
.row-critical td { background: #fff3f0; }
|
||||
|
||||
.charts-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 24px; }
|
||||
.chart-card { background: white; border-radius: 8px; padding: 24px; box-shadow: 0 1px 3px rgba(0,0,0,.06); }
|
||||
.chart-card.full { grid-column: 1 / -1; }
|
||||
@@ -103,18 +119,37 @@ footer { text-align: center; padding: 24px; font-size: 11px; color: #97a0af; }
|
||||
.warning-item .title { font-weight: 600; font-size: 13px; color: #172b4d;}
|
||||
.warning-item .desc { font-size: 12px; color: #5e6c84; }
|
||||
|
||||
/* Popover Tooltips */
|
||||
.pop-trigger { position: relative; display: inline-block; cursor: help; }
|
||||
.pop-content {
|
||||
display: none;
|
||||
position: absolute;
|
||||
bottom: 120%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 30px rgba(23,43,77,.25);
|
||||
border: 1px solid #dfe1e6;
|
||||
padding: 12px;
|
||||
width: 280px;
|
||||
z-index: 100;
|
||||
}
|
||||
.pop-trigger:hover .pop-content { display: block; animation: popIn .2s ease-out; }
|
||||
@keyframes popIn {
|
||||
from { opacity: 0; transform: translateX(-50%) translateY(10px) scale(.95); }
|
||||
to { opacity: 1; transform: translateX(-50%) translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
.pop-item { font-size: 11px; padding: 6px 0; border-bottom: 1px solid #f4f5f7; line-height: 1.4; color: #5e6c84; }
|
||||
.pop-item:last-child { border-bottom: none; }
|
||||
.pop-item a { color: #0052cc; text-decoration: none; font-weight: 600; margin-right: 4px; }
|
||||
.pop-item a:hover { text-decoration: underline; }
|
||||
|
||||
/* Jira Links */
|
||||
.jira-link { color: #0052cc; text-decoration: none; font-weight: bold; border-bottom: 1px dashed rgba(0,82,204,.3); transition: color .2s; }
|
||||
.jira-link:hover { color: #0747a6; border-bottom-style: solid; text-decoration: underline; }
|
||||
|
||||
.jira-link-small { color: #5e6c84; text-decoration: none; font-size: 10px; background: #f4f5f7; padding: 1px 4px; border-radius: 3px; border: 1px solid #dfe1e6; transition: all .2s; margin-right: 4px; display: inline-block; margin-top: 4px; }
|
||||
.jira-link-small:hover { background: #ebecf0; color: #172b4d; border-color: #c1c7d0; text-decoration: none; }
|
||||
|
||||
.ticket-list-inline { margin-top: 6px; display: flex; flex-wrap: wrap; gap: 2px; }
|
||||
|
||||
/* Tooltip improvements (native title) */
|
||||
[title] { cursor: help; }
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.kpi-row, .kpi-row-3 { grid-template-columns: 1fr 1fr; }
|
||||
|
||||
+85
-1
@@ -124,13 +124,97 @@
|
||||
<div class="table-card">
|
||||
<h3>SP Completados — Tabla Resumen</h3>
|
||||
<table>
|
||||
<thead><tr><th>Desarrollador</th><th>✅ SP Completados</th><th>% del impacto total</th></tr></thead>
|
||||
<thead><tr><th>Desarrollador</th><th>✅ SP Completados</th><th>🎟️ Tickets</th><th>% del impacto total</th></tr></thead>
|
||||
<tbody id="tableHistory"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<!-- ── SECCIÓN 3: Control de Calidad (QA) ── -->
|
||||
<div class="section-header qa">
|
||||
<div class="dot qa"></div>
|
||||
<h2>Control de Calidad (QA)</h2>
|
||||
<span style="font-size:12px;color:#5e6c84;margin-left:8px;">Métricas del proceso de QA · Tiempos, colas y devoluciones</span>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
<div class="section-block">
|
||||
|
||||
<!-- KPIs -->
|
||||
<div class="kpi-row">
|
||||
<div class="kpi qa">
|
||||
<div class="kpi-value" id="kpi-qa-tickets">—</div>
|
||||
<div class="kpi-label">Tickets QA</div>
|
||||
<div class="kpi-sub">Procesados en el período</div>
|
||||
</div>
|
||||
<div class="kpi qa-time">
|
||||
<div class="kpi-value" id="kpi-qa-avg">—</div>
|
||||
<div class="kpi-label">Tiempo Prom. QA</div>
|
||||
<div class="kpi-sub">Horas promedio por ticket</div>
|
||||
</div>
|
||||
<div class="kpi qa-queue">
|
||||
<div class="kpi-value" id="kpi-qa-rtqa">—</div>
|
||||
<div class="kpi-label">Tiempo Prom. en Cola</div>
|
||||
<div class="kpi-sub">Horas esperando READY TO QA</div>
|
||||
</div>
|
||||
<div class="kpi returned">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- Gráficos fila 1 -->
|
||||
<div class="charts-grid">
|
||||
<div class="chart-card">
|
||||
<h3>📊 Distribución de Tiempos en QA</h3>
|
||||
<div class="sub">Rangos: <30min · 30min–2hs · 2–8hs · >8hs</div>
|
||||
<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>
|
||||
<div style="position:relative;height:240px"><canvas id="chartQaReturned"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gráficos fila 2 -->
|
||||
<div class="charts-grid">
|
||||
<div class="chart-card">
|
||||
<h3>📈 Tiempo Promedio en QA — Mes a Mes</h3>
|
||||
<div class="sub">Evolución de la velocidad de revisión (horas)</div>
|
||||
<div style="position:relative;height:240px"><canvas id="chartQaMonthly"></canvas></div>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<h3>⏳ Tiempo en Cola RTQA — Mes a Mes</h3>
|
||||
<div class="sub">Horas promedio esperando ser tomado por QA</div>
|
||||
<div style="position:relative;height:240px"><canvas id="chartRtqaMonthly"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabla de tickets QA -->
|
||||
<div class="table-card">
|
||||
<h3>🔍 Detalle de Tickets — QA</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Ticket</th>
|
||||
<th>Resumen</th>
|
||||
<th>⏱️ Tiempo en QA (hs)</th>
|
||||
<th>⏳ Cola RTQA (hs)</th>
|
||||
<th>🔄 Veces devuelto</th>
|
||||
<th>Estado</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tableQA"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<footer>
|
||||
Dashboard en tiempo real · Arquitectura Modular Serverless · Realizado para Vercel & Jira
|
||||
</footer>
|
||||
|
||||
@@ -70,6 +70,7 @@ async function loadAndRender(forceRefresh = false) {
|
||||
|
||||
renderCurrentSection();
|
||||
renderHistorySection();
|
||||
renderQASection();
|
||||
|
||||
} catch(e) {
|
||||
errorBanner.style.display = 'block';
|
||||
@@ -197,19 +198,25 @@ function renderCurrentSection() {
|
||||
const d = ad[p], total = d.in_progress + d.todo;
|
||||
const c = chartsManager.personColor(p);
|
||||
|
||||
const tooltipIP = d.issues_ip.map(i => `${i.key} (${i.sp} SP)`).join('\n');
|
||||
const tooltipTodo = d.issues_todo.map(i => `${i.key} (${i.sp} SP)`).join('\n');
|
||||
const allKeys = [...d.issues_ip, ...d.issues_todo].map(i =>
|
||||
`<a href="${getJiraUrl(i.key)}" target="_blank" class="jira-link-small" title="${i.summary}">${i.key}</a>`
|
||||
).join(' ');
|
||||
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>
|
||||
<div class="ticket-list-inline">${allKeys}</div>
|
||||
</td>
|
||||
<td><span class="pill progress" title="${tooltipIP}">${d.in_progress} SP</span></td>
|
||||
<td><span class="pill todo" title="${tooltipTodo}">${d.todo} SP</span></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>
|
||||
@@ -226,10 +233,13 @@ function renderHistorySection() {
|
||||
|
||||
// Consolidar todos los meses del rango actual
|
||||
let periodData = {};
|
||||
Object.values(data.monthlyDone).forEach(md => {
|
||||
Object.entries(md).forEach(([p, sp]) => {
|
||||
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) + sp;
|
||||
periodData[p] = (periodData[p] || 0) + i.sp;
|
||||
periodTicketCount[p] = (periodTicketCount[p] || 0) + 1;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -251,14 +261,16 @@ function renderHistorySection() {
|
||||
const tbody = document.getElementById('tableHistory');
|
||||
tbody.innerHTML = '';
|
||||
if (sorted.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="3" class="no-data">No hay entregas en este período</td></tr>';
|
||||
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>
|
||||
@@ -280,3 +292,114 @@ function renderHistorySection() {
|
||||
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>`;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -43,5 +43,42 @@ export const chartsManager = {
|
||||
...options,
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
drawDoughnutChart(id, canvasId, labels, data, backgroundColors, options = {}) {
|
||||
this.destroyChart(id);
|
||||
const ctx = document.getElementById(canvasId);
|
||||
if (!ctx) return;
|
||||
|
||||
this.instances[id] = new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [{ data, backgroundColor: backgroundColors, borderWidth: 2, borderColor: '#fff' }],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { position: 'bottom', labels: { font: { size: 11 } } } },
|
||||
...options,
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
drawLineChart(id, canvasId, labels, datasets, options = {}) {
|
||||
this.destroyChart(id);
|
||||
const ctx = document.getElementById(canvasId);
|
||||
if (!ctx) return;
|
||||
|
||||
this.instances[id] = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: { labels, datasets },
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
tension: 0.4,
|
||||
...options,
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user