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:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user