From 1f39c4df7a901d5eed8a473c1571784859682d0d Mon Sep 17 00:00:00 2001 From: Ricardo Gonzalez Date: Sat, 30 May 2026 01:00:02 -0500 Subject: [PATCH] Exportar informe DOCX + DashboardView simplificado a 4 tarjetas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- bun.lock | 19 +++- package.json | 1 + src/components/SCurveChart.vue | 155 +++++++++++--------------- src/services/report-export.ts | 198 +++++++++++++++++++++++++++++++++ src/stores/workitems.ts | 8 -- src/views/DashboardView.vue | 154 +++++++++++++++++-------- 6 files changed, 394 insertions(+), 141 deletions(-) create mode 100644 src/services/report-export.ts diff --git a/bun.lock b/bun.lock index c84a983..f84e13d 100644 --- a/bun.lock +++ b/bun.lock @@ -30,6 +30,7 @@ "clsx": "^2.1.1", "dexie": "^4.0.4", "dnd-kit-vue": "^0.0.2", + "docx": "^9.7.1", "lowlight": "^3.3.0", "lucide-vue-next": "^1.0.0", "mammoth": "^1.12.0", @@ -591,6 +592,8 @@ "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + "@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], + "@types/react": ["@types/react@19.2.15", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], @@ -821,6 +824,8 @@ "dnd-kit-vue": ["dnd-kit-vue@0.0.2", "", { "dependencies": { "@dnd-kit/abstract": "^0.1.19", "@dnd-kit/dom": "^0.1.19", "@dnd-kit/state": "^0.1.19", "@vueuse/core": "^13.4.0", "reka-ui": "^2.3.1" }, "peerDependencies": { "vue": "^3.3.0" } }, "sha512-2ZQfqTulZI7vqFiYscV7VMQRXSEryjanlaCY5BvkDf5i+whEAvOKSckyBa6SK8LCPaF5f/IIcUhfh6TnbaWq3A=="], + "docx": ["docx@9.7.1", "", { "dependencies": { "@types/node": "^25.2.3", "hash.js": "^1.1.7", "jszip": "^3.10.1", "nanoid": "^5.1.3", "xml": "^1.0.1", "xml-js": "^1.6.8" } }, "sha512-ilXFf9Moz47ABjFpDiA5s1w9lpb4EFSp7+5iiJSbfyYDM+bpZdAgLlSr7fW4aXhVe/E+F6QCv0EvRVFEd5CsWg=="], + "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], @@ -977,6 +982,8 @@ "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + "hash.js": ["hash.js@1.1.7", "", { "dependencies": { "inherits": "^2.0.3", "minimalistic-assert": "^1.0.1" } }, "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA=="], + "hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="], "he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="], @@ -1145,6 +1152,8 @@ "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], + "minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="], + "minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], @@ -1155,7 +1164,7 @@ "muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="], - "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], + "nanoid": ["nanoid@5.1.11", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg=="], "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], @@ -1453,6 +1462,8 @@ "undici": ["undici@7.25.0", "", {}, "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ=="], + "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], @@ -1509,6 +1520,10 @@ "xlsx": ["xlsx@0.18.5", "", { "dependencies": { "adler-32": "~1.3.0", "cfb": "~1.2.1", "codepage": "~1.15.0", "crc-32": "~1.2.1", "ssf": "~0.11.2", "wmf": "~1.0.1", "word": "~0.3.0" }, "bin": { "xlsx": "bin/xlsx.njs" } }, "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ=="], + "xml": ["xml@1.0.1", "", {}, "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw=="], + + "xml-js": ["xml-js@1.6.11", "", { "dependencies": { "sax": "^1.2.4" }, "bin": { "xml-js": "./bin/cli.js" } }, "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g=="], + "xmlbuilder": ["xmlbuilder@10.1.1", "", {}, "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg=="], "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], @@ -1589,6 +1604,8 @@ "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + "postcss/nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], + "recast-x/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "reka-ui/@internationalized/date": ["@internationalized/date@3.12.1", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-6IedsVWXyq4P9Tj+TxuU8WGWM70hYLl12nbYU8jkikVpa6WXapFazPUcHUMDMoWftIDE2ILDkFFte6W2nFCkRQ=="], diff --git a/package.json b/package.json index 021e230..dceba14 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "clsx": "^2.1.1", "dexie": "^4.0.4", "dnd-kit-vue": "^0.0.2", + "docx": "^9.7.1", "lowlight": "^3.3.0", "lucide-vue-next": "^1.0.0", "mammoth": "^1.12.0", diff --git a/src/components/SCurveChart.vue b/src/components/SCurveChart.vue index 44a39d4..ea79148 100644 --- a/src/components/SCurveChart.vue +++ b/src/components/SCurveChart.vue @@ -1,13 +1,10 @@ diff --git a/src/services/report-export.ts b/src/services/report-export.ts new file mode 100644 index 0000000..292e78b --- /dev/null +++ b/src/services/report-export.ts @@ -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 { + 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) +} diff --git a/src/stores/workitems.ts b/src/stores/workitems.ts index febf5e0..70c1076 100644 --- a/src/stores/workitems.ts +++ b/src/stores/workitems.ts @@ -185,14 +185,6 @@ export const useWorkItemsStore = defineStore('workitems', () => { // 4. Actualizar UI con datos frescos de KAPPA userStories.value = stories.map(hu => enrichHU(hu, id)) - if (userStories.value.length > 0) { - console.log('[Alpha DEBUG] 1ra HU desc:', { - id: userStories.value[0].id, - title: userStories.value[0].title?.slice(0, 40), - hasDescription: !!userStories.value[0].description, - descLength: userStories.value[0].description?.length, - }) - } epics.value = epicData.map(epic => ({ ...epic, diff --git a/src/views/DashboardView.vue b/src/views/DashboardView.vue index dd8bfae..86200b0 100644 --- a/src/views/DashboardView.vue +++ b/src/views/DashboardView.vue @@ -6,13 +6,14 @@ import { useWorkItemsStore } from '@/stores/workitems' import { useUsersStore } from '@/stores/users' import { storage } from '@/services/storage' import { getTypeLabel, getTypeColor, getTypeIcon } from '@/services/hierarchy' -import { Activity, FileText, Layers, Clock, Info, AlertTriangle, Plus, Brain, Sparkles, Loader2, CheckCircle2, XCircle, Send, ChevronDown, Eye } from 'lucide-vue-next' +import { Activity, FileText, Layers, Clock, Info, AlertTriangle, Plus, Brain, Sparkles, Loader2, CheckCircle2, XCircle, Send, ChevronDown, Eye, TrendingUp } from 'lucide-vue-next' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' import HuDrafts from '@/components/HuDrafts.vue' import AiProjectChat from '@/components/AiProjectChat.vue' import PrioritizerCard from '@/components/PrioritizerCard.vue' import SCurveChart from '@/components/SCurveChart.vue' +import { generateReportDocx } from '@/services/report-export' import { analyzeProjectEpics, analyzeProjectHUs, saveEpicDrafts, saveHUDrafts } from '@/services/project-analyzer' import { getDrafts, deleteDraft, type HuDraftRecord } from '@/services/hu-drafts-db' import { kappa } from '@/services/kappa-api' @@ -42,10 +43,10 @@ const usersStore = useUsersStore() const project = computed(() => projects.selected) // ─── Inline notification (funciona en Tauri) ───────── -const notification = ref<{ type: 'success' | 'error'; message: string } | null>(null) +const notification = ref<{ type: 'success' | 'error' | 'info'; message: string } | null>(null) let notifTimeout: ReturnType | null = null -function showNotif(type: 'success' | 'error', message: string) { +function showNotif(type: 'success' | 'error' | 'info', message: string) { if (notifTimeout) clearTimeout(notifTimeout) notification.value = { type, message } notifTimeout = setTimeout(() => { notification.value = null }, 5000) @@ -410,6 +411,58 @@ async function discardDraft(id: string) { await loadDrafts() } +// ─── Export report ────────────────────────────────── +async function exportReport() { + if (!project.value) return + showNotif('info', 'Generando informe DOCX...') + try { + const doneCount = workItems.userStories.filter(hu => { + const s = String(hu.status ?? '').toLowerCase() + return ['done', 'completed', 'closed', 'finalizado', '5', '6', '7', 'qa-client', 'ready to deploy'].includes(s) + }).length + + const blob = await generateReportDocx({ + projectId: project.value.id, + projectName: project.value.name || '', + epicCount: workItems.totalEpics, + huCount: workItems.totalHUs, + inProgressCount: workItems.inProgressHUs, + doneCount, + blockedCount: workItems.userStories.filter(hu => { + const s = String(hu.status ?? '').toLowerCase() + return ['blocked', 'bloqueado'].includes(s) + }).length, + epics: workItems.epics, + hus: workItems.userStories, + totalSessions: workItems.totalSessions, + metrics: { + totalSpPlanned: workItems.userStories.reduce((s, h) => s + (h.story_points || 0), 0), + totalSpCompleted: workItems.userStories.filter(hu => { + const s = String(hu.status ?? '').toLowerCase() + return ['done', 'completed', 'closed', 'finalizado', '5', '6', '7', 'qa-client', 'ready to deploy'].includes(s) + }).reduce((s, h) => s + (h.story_points || 0), 0), + velocityPerWeek: 0, + spi: 1, + estimatedEndDate: '', + clientBlockedHours: 0, + totalBlockedHours: 0, + }, + generatedAt: new Date().toLocaleDateString('es-CO', { year: 'numeric', month: 'long', day: 'numeric' }), + }) + + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `informe_${project.value.name?.replace(/[^a-z0-9]/gi, '_').toLowerCase() || 'proyecto'}.docx` + a.click() + URL.revokeObjectURL(url) + showNotif('success', 'Informe descargado') + } catch (e: any) { + console.error('[Alpha] Export error:', e) + showNotif('error', `Error al generar informe: ${e.message}`) + } +} + // ─── Project analysis — Two-phase ───────────────────────── const phase = ref<'idle' | 'epics' | 'hus' | 'done'>('idle') const analyzing = ref(false) @@ -599,53 +652,56 @@ const statusLabel = (status: unknown) => {
{{ project.key }} +
- -
+ +
- {{ t('dashboard.epics') }} - + {{ t('dashboard.epics') }} · {{ t('dashboard.hus') }} · {{ t('dashboard.inProgress') }} + - -
{{ workItems.totalEpics }}
-

{{ t('dashboard.epicsSubtitle') }}

-
-
- - - - {{ t('dashboard.hus') }} - - - -
{{ workItems.totalHUs }}
-

{{ t('dashboard.husSubtitle') }}

-
-
- - - - {{ t('dashboard.inProgress') }} - - - -
{{ workItems.inProgressHUs }}
-

{{ t('dashboard.activeHus') }}

+ +
+ {{ workItems.totalEpics }} + {{ t('dashboard.epics') }} +
+
+ {{ workItems.totalHUs }} + HUs +
+
+ {{ workItems.inProgressHUs }} + En prog. +
+
+
+
- QA - + QA + -
{{ qaMetrics.total }}
-

Planes QA · {{ qaMetrics.totalCases }} casos

-
+
{{ qaMetrics.total }}
+

Planes QA · {{ qaMetrics.totalCases }} casos

+
{{ qaMetrics.auto }} auto {{ qaMetrics.parcial }} parcial {{ qaMetrics.manual }} manual @@ -655,12 +711,22 @@ const statusLabel = (status: unknown) => { - {{ t('dashboard.sessions') }} - + {{ t('dashboard.sessions') }} + -
{{ workItems.totalSessions }}
-

{{ t('dashboard.sessionsSubtitle') }}

+
{{ workItems.totalSessions }}
+

{{ t('dashboard.sessionsSubtitle') }}

+
+
+ + + + Curva S + + + +
@@ -668,9 +734,6 @@ const statusLabel = (status: unknown) => { - - - {
+

{{ notification.type === 'success' ? 'Completado' : 'Error' }}