Exportar informe DOCX + DashboardView simplificado a 4 tarjetas

- services/report-export.ts: generación de DOCX con docx (npm)
  incluye: estado general, épicas, tabla HUs, bloqueos, curva S, sesiones
- DashboardView: botón Exportar al lado del badge del proyecto
  stats simplificados a 4 tarjetas (combinada épicas/HUs/progreso, QA, sesiones, curva S)
- SCurveChart: modo compacto con área gradient fill estilo shadcn
- Notificación inline: soporte para tipo 'info' (azul)
This commit is contained in:
2026-05-30 01:00:02 -05:00
parent 97950adf8b
commit 1f39c4df7a
6 changed files with 394 additions and 141 deletions
+198
View File
@@ -0,0 +1,198 @@
import {
Document, Packer, Paragraph, TextRun, Table, TableRow, TableCell,
HeadingLevel, AlignmentType, WidthType, ShadingType,
} from 'docx'
import type { EnrichedUserStory, EnrichedEpic } from '@/stores/workitems'
import { getSessionsByProject, getSessionSummary, getProjectState } from '@/services/transcriptions-db'
export interface ReportData {
projectId: number
projectName: string
epicCount: number
huCount: number
inProgressCount: number
doneCount: number
blockedCount: number
epics: EnrichedEpic[]
hus: EnrichedUserStory[]
totalSessions: number
metrics: {
totalSpPlanned: number
totalSpCompleted: number
velocityPerWeek: number
spi: number
estimatedEndDate: string
clientBlockedHours: number
totalBlockedHours: number
}
generatedAt: string
}
function cell(text: string, bold = false, shading?: string): TableCell {
return new TableCell({
children: [new Paragraph({
children: [new TextRun({ text, bold, size: '20pt' })],
spacing: { before: 40, after: 40 },
})],
shading: shading ? { type: ShadingType.CLEAR, fill: shading } : undefined,
})
}
function headerRow(...headers: string[]): TableRow {
return new TableRow({ children: headers.map(h => cell(h, true, 'F2F2F2')) })
}
function dataRow(...values: string[]): TableRow {
return new TableRow({ children: values.map(v => cell(v)) })
}
export async function generateReportDocx(data: ReportData): Promise<Blob> {
const sessions = await getSessionsByProject(data.projectId)
const state = await getProjectState(data.projectId)
const summary = state?.summary || 'Sin resumen disponible'
const doc = new Document({
sections: [{
children: [
// ═══════════ TITLE ═══════════
new Paragraph({
text: `${data.projectName} — Informe de Avance del Proyecto`,
heading: HeadingLevel.TITLE,
alignment: AlignmentType.CENTER,
spacing: { after: 100 },
}),
new Paragraph({
spacing: { after: 400 },
children: [
new TextRun({ text: `Fecha de corte: ${data.generatedAt}`, size: '20pt' }),
],
}),
// ═══════════ 1. ESTADO GENERAL ═══════════
new Paragraph({ text: '1. Estado General', heading: HeadingLevel.HEADING_1, spacing: { before: 400, after: 200 } }),
new Table({
rows: [
headerRow('Métrica', 'Valor'),
dataRow('Total Épicas', String(data.epicCount)),
dataRow('Total HUs', String(data.huCount)),
dataRow('En Progreso', String(data.inProgressCount)),
dataRow('Completadas', String(data.doneCount)),
dataRow('Bloqueadas', String(data.blockedCount)),
dataRow('SP Planificados', String(data.metrics.totalSpPlanned)),
dataRow('SP Completados', String(data.metrics.totalSpCompleted)),
],
width: { size: 100, type: WidthType.PERCENTAGE },
}),
// ═══════════ 2. RESUMEN EJECUTIVO ═══════════
new Paragraph({ text: '2. Resumen Ejecutivo', heading: HeadingLevel.HEADING_1, spacing: { before: 400, after: 200 } }),
new Paragraph({
spacing: { after: 200 },
children: [new TextRun({ text: summary, size: '20pt' })],
}),
...(data.metrics.totalBlockedHours > 0 ? [
new Paragraph({
spacing: { before: 100 },
children: [new TextRun({
text: `⏱ Horas bloqueadas: ${data.metrics.totalBlockedHours}h totales (${data.metrics.clientBlockedHours}h imputables al cliente)`,
size: '20pt',
bold: true,
})],
}),
] : []),
// ═══════════ 3. ÉPICAS ═══════════
new Paragraph({ text: '3. Épicas', heading: HeadingLevel.HEADING_1, spacing: { before: 400, after: 200 } }),
...data.epics.map(epic => new Paragraph({
spacing: { before: 80, after: 40 },
children: [
new TextRun({ text: `📦 ${(epic as any)._epicCode || `EP-${epic.id}`}`, bold: true, size: '20pt' }),
new TextRun({ text: ` ${(epic as any)._cleanName || epic.name || epic.title || ''}`, size: '20pt' }),
],
})),
// ═══════════ 4. HUs ═══════════
new Paragraph({ text: '4. HUs — Resumen', heading: HeadingLevel.HEADING_1, spacing: { before: 400, after: 200 } }),
new Table({
rows: [
headerRow('Código', 'Título', 'Estado', 'Prioridad', 'SP'),
...data.hus.slice(0, 50).map(hu => dataRow(
hu.code || `#${hu.id}`,
(hu._cleanTitle || hu.title).slice(0, 60),
hu._statusName || String(hu.status || '—'),
String(hu.priority || '—'),
String(hu.story_points ?? '—'),
)),
],
width: { size: 100, type: WidthType.PERCENTAGE },
}),
new Paragraph({
spacing: { before: 100 },
children: [new TextRun({ text: `Mostrando 50 de ${data.huCount} HUs`, size: '20pt', italics: true, color: '888888' })],
}),
// ═══════════ 5. HUs BLOQUEADAS ═══════════
...(data.blockedCount > 0 ? [
new Paragraph({ text: '5. HUs Bloqueadas', heading: HeadingLevel.HEADING_1, spacing: { before: 400, after: 200 } }),
...data.hus.filter(h => {
const s = String(h.status ?? '').toLowerCase()
return ['blocked', 'bloqueado'].includes(s)
}).map(hu => new Paragraph({
spacing: { before: 60, after: 60 },
children: [new TextRun({ text: `${hu._cleanTitle || hu.title}`, size: '20pt' })],
})),
] : []),
// ═══════════ 6. CURVA S ═══════════
new Paragraph({ text: '6. Curva S del Proyecto', heading: HeadingLevel.HEADING_1, spacing: { before: 400, after: 200 } }),
new Paragraph({
children: [new TextRun({
text: `La curva S muestra el avance planificado vs real. SPI actual: ${data.metrics.spi}. Velocidad: ${data.metrics.velocityPerWeek} SP/semana. Proyección de finalización: ${data.metrics.estimatedEndDate || 'N/A'}.`,
size: '20pt',
})],
spacing: { after: 200 },
}),
// ═══════════ 7. CURVA S ═══════════
new Paragraph({ text: '7. Carga de Trabajo Actual', heading: HeadingLevel.HEADING_1, spacing: { before: 400, after: 200 } }),
new Paragraph({
children: [new TextRun({
text: `${data.inProgressCount} HUs en progreso · ${data.blockedCount} bloqueadas · ${data.doneCount} completadas de ${data.huCount} totales.`,
size: '20pt',
})],
}),
// ═══════════ 8. AVANCES DE LA SEMANA ═══════════
...(sessions.length > 0 ? [
new Paragraph({ text: '8. Avances Registrados', heading: HeadingLevel.HEADING_1, spacing: { before: 400, after: 200 } }),
...(await Promise.all(sessions.slice(-10).reverse().map(async (s) => {
const sum = await getSessionSummary(s.id!)
return new Paragraph({
spacing: { before: 60, after: 60 },
children: [
new TextRun({ text: `📅 ${s.date}`, bold: true, size: '20pt' }),
new TextRun({ text: `${s.title}`, size: '20pt' }),
...(sum?.summary ? [new TextRun({ text: `\n${sum.summary.slice(0, 300)}`, size: '20pt', italics: true, color: '555555' })] : []),
],
})
}))),
] : []),
// ═══════════ FOOTER ═══════════
new Paragraph({
spacing: { before: 600 },
alignment: AlignmentType.CENTER,
children: [new TextRun({
text: `Documento generado automáticamente por Alpha — ${data.generatedAt}`,
size: '20pt', italics: true, color: '888888',
})],
}),
],
}],
})
return await Packer.toBlob(doc)
}