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:
@@ -30,6 +30,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dexie": "^4.0.4",
|
"dexie": "^4.0.4",
|
||||||
"dnd-kit-vue": "^0.0.2",
|
"dnd-kit-vue": "^0.0.2",
|
||||||
|
"docx": "^9.7.1",
|
||||||
"lowlight": "^3.3.0",
|
"lowlight": "^3.3.0",
|
||||||
"lucide-vue-next": "^1.0.0",
|
"lucide-vue-next": "^1.0.0",
|
||||||
"mammoth": "^1.12.0",
|
"mammoth": "^1.12.0",
|
||||||
@@ -591,6 +592,8 @@
|
|||||||
|
|
||||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
"@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": ["@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=="],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
|
||||||
@@ -977,6 +982,8 @@
|
|||||||
|
|
||||||
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
|
||||||
|
|
||||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||||
@@ -1155,7 +1164,7 @@
|
|||||||
|
|
||||||
"muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="],
|
"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=="],
|
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
||||||
|
|
||||||
@@ -1453,6 +1462,8 @@
|
|||||||
|
|
||||||
"undici": ["undici@7.25.0", "", {}, "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ=="],
|
"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=="],
|
"universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
|
||||||
|
|
||||||
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
|
"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=="],
|
"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=="],
|
"xmlbuilder": ["xmlbuilder@10.1.1", "", {}, "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg=="],
|
||||||
|
|
||||||
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"reka-ui/@internationalized/date": ["@internationalized/date@3.12.1", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-6IedsVWXyq4P9Tj+TxuU8WGWM70hYLl12nbYU8jkikVpa6WXapFazPUcHUMDMoWftIDE2ILDkFFte6W2nFCkRQ=="],
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dexie": "^4.0.4",
|
"dexie": "^4.0.4",
|
||||||
"dnd-kit-vue": "^0.0.2",
|
"dnd-kit-vue": "^0.0.2",
|
||||||
|
"docx": "^9.7.1",
|
||||||
"lowlight": "^3.3.0",
|
"lowlight": "^3.3.0",
|
||||||
"lucide-vue-next": "^1.0.0",
|
"lucide-vue-next": "^1.0.0",
|
||||||
"mammoth": "^1.12.0",
|
"mammoth": "^1.12.0",
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, onMounted } from 'vue'
|
import { ref, computed, watch, onMounted } from 'vue'
|
||||||
import { useWorkItemsStore } from '@/stores/workitems'
|
import { useWorkItemsStore } from '@/stores/workitems'
|
||||||
import { calculateSCurve, type CurvePoint, type SCurveMetrics, type SCurveData } from '@/services/s-curve'
|
import { calculateSCurve, type CurvePoint, type SCurveData } from '@/services/s-curve'
|
||||||
import { getBlockers, getBlockerStats } from '@/services/blocker-log'
|
import { getBlockers } from '@/services/blocker-log'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { TrendingUp, AlertTriangle } from 'lucide-vue-next'
|
|
||||||
|
|
||||||
const props = defineProps<{ projectId: number }>()
|
const props = defineProps<{ projectId: number; compact?: boolean }>()
|
||||||
|
|
||||||
const workItems = useWorkItemsStore()
|
const workItems = useWorkItemsStore()
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
@@ -28,23 +25,31 @@ async function load() {
|
|||||||
onMounted(load)
|
onMounted(load)
|
||||||
watch(() => workItems.userStories.length, load)
|
watch(() => workItems.userStories.length, load)
|
||||||
|
|
||||||
const CHART_W = 600
|
const CHART_W = 400
|
||||||
const CHART_H = 250
|
const CHART_H = 150
|
||||||
const PAD = { top: 20, right: 20, bottom: 35, left: 50 }
|
const PAD = { top: 10, right: 10, bottom: 20, left: 35 }
|
||||||
|
|
||||||
function buildPath(points: CurvePoint[], maxSp: number, totalDays: number): string {
|
function buildAreaPath(points: CurvePoint[], maxSp: number, totalDays: number): string {
|
||||||
if (points.length === 0) return ''
|
if (points.length === 0) return ''
|
||||||
const w = CHART_W - PAD.left - PAD.right
|
const w = CHART_W - PAD.left - PAD.right
|
||||||
const h = CHART_H - PAD.top - PAD.bottom
|
const h = CHART_H - PAD.top - PAD.bottom
|
||||||
const firstDate = points[0].date
|
const firstDate = points[0].date
|
||||||
return points.map((p, i) => {
|
const line = points.map((p, i) => {
|
||||||
const first = new Date(firstDate)
|
const dx = dayOffset(p.date, firstDate)
|
||||||
const cur = new Date(p.date)
|
|
||||||
const dx = Math.max(0, Math.ceil((cur.getTime() - first.getTime()) / (1000 * 60 * 60 * 24)))
|
|
||||||
const x = PAD.left + (dx / totalDays) * w
|
const x = PAD.left + (dx / totalDays) * w
|
||||||
const y = PAD.top + h - (p.cumulative / maxSp) * h
|
const y = PAD.top + h - (p.cumulative / maxSp) * h
|
||||||
return `${i === 0 ? 'M' : 'L'}${x},${y}`
|
return `${i === 0 ? 'M' : 'L'}${x},${y}`
|
||||||
}).join(' ')
|
}).join(' ')
|
||||||
|
// Close the area for fill
|
||||||
|
const last = points[points.length - 1]
|
||||||
|
const lastDx = dayOffset(last.date, firstDate)
|
||||||
|
const lastX = PAD.left + (lastDx / totalDays) * w
|
||||||
|
const bottomY = PAD.top + h
|
||||||
|
return `${line} L${lastX},${bottomY} L${PAD.left},${bottomY} Z`
|
||||||
|
}
|
||||||
|
|
||||||
|
function dayOffset(date: string, from: string): number {
|
||||||
|
return Math.max(0, Math.ceil((new Date(date).getTime() - new Date(from).getTime()) / (1000 * 60 * 60 * 24)))
|
||||||
}
|
}
|
||||||
|
|
||||||
const planned = computed(() => sCurve.value?.planned || [])
|
const planned = computed(() => sCurve.value?.planned || [])
|
||||||
@@ -66,84 +71,60 @@ const totalDays = computed(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Card id="scurve-chart">
|
<div v-if="loading" class="text-[10px] text-muted-foreground text-center py-4">Cargando...</div>
|
||||||
<CardHeader class="pb-3">
|
<div v-else-if="planned.length === 0" class="text-[10px] text-muted-foreground text-center py-4">Sin datos</div>
|
||||||
<CardTitle class="text-sm font-medium flex items-center gap-2">
|
<div v-else class="space-y-2">
|
||||||
<TrendingUp class="size-4" />
|
<!-- Mini metrics row for compact -->
|
||||||
Curva S del proyecto
|
<div v-if="metrics && compact" class="flex items-center gap-2 text-[10px] text-muted-foreground px-1">
|
||||||
</CardTitle>
|
<span class="font-mono font-bold text-foreground">{{ metrics.totalSpPlanned }} SP</span>
|
||||||
</CardHeader>
|
<span class="text-muted-foreground/50">|</span>
|
||||||
<CardContent>
|
<span :class="metrics.spi >= 1 ? 'text-green-500' : 'text-red-500'">SPI {{ metrics.spi }}</span>
|
||||||
<div v-if="loading" class="text-xs text-muted-foreground text-center py-8">Cargando...</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="planned.length === 0" class="text-xs text-muted-foreground text-center py-8">
|
<!-- SVG Chart -->
|
||||||
No hay suficientes datos. Se necesitan HUs con story points y fechas.
|
<svg :viewBox="`0 0 ${CHART_W} ${CHART_H}`" class="w-full h-auto">
|
||||||
</div>
|
<defs>
|
||||||
|
<linearGradient id="planGrad" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#3b82f6" stop-opacity="0.3" />
|
||||||
|
<stop offset="100%" stop-color="#3b82f6" stop-opacity="0.02" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="actualGrad" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#22c55e" stop-opacity="0.25" />
|
||||||
|
<stop offset="100%" stop-color="#22c55e" stop-opacity="0.02" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
<div v-else class="space-y-4">
|
<!-- Grid -->
|
||||||
<!-- Metrics row -->
|
<line v-for="i in 3" :key="i"
|
||||||
<div v-if="metrics" class="grid grid-cols-4 gap-2 text-xs">
|
:x1="PAD.left" :y1="PAD.top + ((i-1)/2) * (CHART_H - PAD.top - PAD.bottom)"
|
||||||
<div class="p-2 rounded-lg bg-muted/30">
|
:x2="CHART_W - PAD.right" :y2="PAD.top + ((i-1)/2) * (CHART_H - PAD.top - PAD.bottom)"
|
||||||
<span class="text-muted-foreground">SP total</span>
|
stroke="currentColor" stroke-opacity="0.08" stroke-dasharray="3,3"
|
||||||
<p class="font-bold text-sm">{{ metrics.totalSpPlanned }}</p>
|
/>
|
||||||
</div>
|
|
||||||
<div class="p-2 rounded-lg bg-muted/30">
|
|
||||||
<span class="text-muted-foreground">Velocidad/sem</span>
|
|
||||||
<p class="font-bold text-sm">{{ metrics.velocityPerWeek }} SP</p>
|
|
||||||
</div>
|
|
||||||
<div class="p-2 rounded-lg bg-muted/30">
|
|
||||||
<span class="text-muted-foreground">SPI</span>
|
|
||||||
<p class="font-bold text-sm" :class="metrics.spi >= 1 ? 'text-green-500' : 'text-red-500'">
|
|
||||||
{{ metrics.spi }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="p-2 rounded-lg bg-muted/30">
|
|
||||||
<span class="text-muted-foreground">Proyección</span>
|
|
||||||
<p class="font-bold text-sm">{{ metrics.estimatedEndDate || '—' }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Blockers summary -->
|
<!-- Area fills -->
|
||||||
<div v-if="metrics && metrics.totalBlockedHours > 0" class="flex items-center gap-2 text-xs p-2 rounded-lg bg-orange-500/5 border border-orange-500/20">
|
<path :d="buildAreaPath(planned, maxSp, totalDays)" fill="url(#planGrad)" />
|
||||||
<AlertTriangle class="size-3.5 text-orange-500 shrink-0" />
|
<path :d="buildAreaPath(actual, maxSp, totalDays)" fill="url(#actualGrad)" />
|
||||||
<span class="text-muted-foreground">
|
|
||||||
<strong class="text-orange-600">{{ metrics.totalBlockedHours }}h</strong> bloqueadas
|
|
||||||
(<strong class="text-orange-600">{{ metrics.clientBlockedHours }}h</strong> imputables al cliente)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- SVG Chart -->
|
<!-- Lines -->
|
||||||
<svg :viewBox="`0 0 ${CHART_W} ${CHART_H}`" class="w-full h-auto">
|
<path :d="buildAreaPath(planned, maxSp, totalDays).replace(/Z$/, '')" fill="none" stroke="#3b82f6" stroke-width="1.5" />
|
||||||
<line v-for="i in 5" :key="i"
|
<path :d="buildAreaPath(actual, maxSp, totalDays).replace(/Z$/, '')" fill="none" stroke="#22c55e" stroke-width="1.5" />
|
||||||
:x1="PAD.left" :y1="PAD.top + ((i-1)/4) * (CHART_H - PAD.top - PAD.bottom)"
|
|
||||||
:x2="CHART_W - PAD.right" :y2="PAD.top + ((i-1)/4) * (CHART_H - PAD.top - PAD.bottom)"
|
|
||||||
stroke="currentColor" stroke-opacity="0.1" stroke-dasharray="4,4"
|
|
||||||
/>
|
|
||||||
<path :d="buildPath(planned, maxSp, totalDays)" fill="none" stroke="#3b82f6" stroke-width="2" />
|
|
||||||
<path :d="buildPath(actual, maxSp, totalDays)" fill="none" stroke="#22c55e" stroke-width="2" />
|
|
||||||
|
|
||||||
<text v-for="i in 5" :key="'y'+i"
|
<!-- Y labels -->
|
||||||
:x="PAD.left - 8"
|
<text v-for="i in 3" :key="'y'+i"
|
||||||
:y="PAD.top + ((i-1)/4) * (CHART_H - PAD.top - PAD.bottom) + 4"
|
:x="PAD.left - 4"
|
||||||
text-anchor="end" class="fill-muted-foreground text-[10px]"
|
:y="PAD.top + ((i-1)/2) * (CHART_H - PAD.top - PAD.bottom) + 3"
|
||||||
>{{ Math.round(maxSp * (i-1) / 4) }}</text>
|
text-anchor="end" class="fill-muted-foreground text-[8px]"
|
||||||
|
>{{ Math.round(maxSp * (i-1) / 2) }}</text>
|
||||||
|
|
||||||
<text :x="PAD.left" :y="CHART_H - 5" text-anchor="middle" class="fill-muted-foreground text-[9px]">
|
<!-- X labels -->
|
||||||
{{ planned[0]?.date?.slice(5) || '' }}
|
<text :x="PAD.left" :y="CHART_H - 4" text-anchor="middle" class="fill-muted-foreground text-[8px]">{{ planned[0]?.date?.slice(5) || '' }}</text>
|
||||||
</text>
|
<text :x="CHART_W - PAD.right" :y="CHART_H - 4" text-anchor="middle" class="fill-muted-foreground text-[8px]">{{ planned[planned.length - 1]?.date?.slice(5) || '' }}</text>
|
||||||
<text :x="(CHART_W - PAD.right + PAD.left) / 2" :y="CHART_H - 5" text-anchor="middle" class="fill-muted-foreground text-[9px]">
|
|
||||||
{{ planned[Math.floor(planned.length/2)]?.date?.slice(5) || '' }}
|
|
||||||
</text>
|
|
||||||
<text :x="CHART_W - PAD.right" :y="CHART_H - 5" text-anchor="middle" class="fill-muted-foreground text-[9px]">
|
|
||||||
{{ planned[planned.length - 1]?.date?.slice(5) || '' }}
|
|
||||||
</text>
|
|
||||||
|
|
||||||
<line x1="20" y1="12" x2="40" y2="12" stroke="#3b82f6" stroke-width="2" />
|
<!-- Mini legend -->
|
||||||
<text x="45" y="16" class="fill-muted-foreground text-[9px]">Plan</text>
|
<circle cx="20" cy="8" r="3" fill="#3b82f6" />
|
||||||
<line x1="90" y1="12" x2="110" y2="12" stroke="#22c55e" stroke-width="2" />
|
<text x="27" y="12" class="fill-muted-foreground text-[8px]">Plan</text>
|
||||||
<text x="115" y="16" class="fill-muted-foreground text-[9px]">Real</text>
|
<circle cx="65" cy="8" r="3" fill="#22c55e" />
|
||||||
</svg>
|
<text x="72" y="12" class="fill-muted-foreground text-[8px]">Real</text>
|
||||||
</div>
|
</svg>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -185,14 +185,6 @@ export const useWorkItemsStore = defineStore('workitems', () => {
|
|||||||
|
|
||||||
// 4. Actualizar UI con datos frescos de KAPPA
|
// 4. Actualizar UI con datos frescos de KAPPA
|
||||||
userStories.value = stories.map(hu => enrichHU(hu, id))
|
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 => ({
|
epics.value = epicData.map(epic => ({
|
||||||
...epic,
|
...epic,
|
||||||
|
|||||||
+109
-45
@@ -6,13 +6,14 @@ import { useWorkItemsStore } from '@/stores/workitems'
|
|||||||
import { useUsersStore } from '@/stores/users'
|
import { useUsersStore } from '@/stores/users'
|
||||||
import { storage } from '@/services/storage'
|
import { storage } from '@/services/storage'
|
||||||
import { getTypeLabel, getTypeColor, getTypeIcon } from '@/services/hierarchy'
|
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 { Button } from '@/components/ui/button'
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||||
import HuDrafts from '@/components/HuDrafts.vue'
|
import HuDrafts from '@/components/HuDrafts.vue'
|
||||||
import AiProjectChat from '@/components/AiProjectChat.vue'
|
import AiProjectChat from '@/components/AiProjectChat.vue'
|
||||||
import PrioritizerCard from '@/components/PrioritizerCard.vue'
|
import PrioritizerCard from '@/components/PrioritizerCard.vue'
|
||||||
import SCurveChart from '@/components/SCurveChart.vue'
|
import SCurveChart from '@/components/SCurveChart.vue'
|
||||||
|
import { generateReportDocx } from '@/services/report-export'
|
||||||
import { analyzeProjectEpics, analyzeProjectHUs, saveEpicDrafts, saveHUDrafts } from '@/services/project-analyzer'
|
import { analyzeProjectEpics, analyzeProjectHUs, saveEpicDrafts, saveHUDrafts } from '@/services/project-analyzer'
|
||||||
import { getDrafts, deleteDraft, type HuDraftRecord } from '@/services/hu-drafts-db'
|
import { getDrafts, deleteDraft, type HuDraftRecord } from '@/services/hu-drafts-db'
|
||||||
import { kappa } from '@/services/kappa-api'
|
import { kappa } from '@/services/kappa-api'
|
||||||
@@ -42,10 +43,10 @@ const usersStore = useUsersStore()
|
|||||||
const project = computed(() => projects.selected)
|
const project = computed(() => projects.selected)
|
||||||
|
|
||||||
// ─── Inline notification (funciona en Tauri) ─────────
|
// ─── 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<typeof setTimeout> | null = null
|
let notifTimeout: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
function showNotif(type: 'success' | 'error', message: string) {
|
function showNotif(type: 'success' | 'error' | 'info', message: string) {
|
||||||
if (notifTimeout) clearTimeout(notifTimeout)
|
if (notifTimeout) clearTimeout(notifTimeout)
|
||||||
notification.value = { type, message }
|
notification.value = { type, message }
|
||||||
notifTimeout = setTimeout(() => { notification.value = null }, 5000)
|
notifTimeout = setTimeout(() => { notification.value = null }, 5000)
|
||||||
@@ -410,6 +411,58 @@ async function discardDraft(id: string) {
|
|||||||
await loadDrafts()
|
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 ─────────────────────────
|
// ─── Project analysis — Two-phase ─────────────────────────
|
||||||
const phase = ref<'idle' | 'epics' | 'hus' | 'done'>('idle')
|
const phase = ref<'idle' | 'epics' | 'hus' | 'done'>('idle')
|
||||||
const analyzing = ref(false)
|
const analyzing = ref(false)
|
||||||
@@ -599,53 +652,56 @@ const statusLabel = (status: unknown) => {
|
|||||||
</h1>
|
</h1>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Badge v-if="project.key" variant="outline" class="text-xs">{{ project.key }}</Badge>
|
<Badge v-if="project.key" variant="outline" class="text-xs">{{ project.key }}</Badge>
|
||||||
|
<button
|
||||||
|
class="ml-auto text-[11px] text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
|
||||||
|
@click="exportReport"
|
||||||
|
title="Exportar informe DOCX"
|
||||||
|
>
|
||||||
|
<FileText class="size-3.5" />
|
||||||
|
Exportar
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Stats -->
|
<!-- Stats: 4 cards -->
|
||||||
<div id="dashboard-stats" class="grid gap-3 @xl:grid-cols-2 @3xl:grid-cols-5">
|
<div id="dashboard-stats" class="grid gap-3 @xl:grid-cols-2 @3xl:grid-cols-4">
|
||||||
<Card id="dashboard-stats-epics" class="bg-gradient-to-t from-primary/5 to-card shadow-xs dark:bg-card">
|
<Card id="dashboard-stats-epics" class="bg-gradient-to-t from-primary/5 to-card shadow-xs dark:bg-card">
|
||||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle class="text-sm font-medium">{{ t('dashboard.epics') }}</CardTitle>
|
<CardTitle class="text-xs font-medium">{{ t('dashboard.epics') }} · {{ t('dashboard.hus') }} · {{ t('dashboard.inProgress') }}</CardTitle>
|
||||||
<Layers class="size-4 text-muted-foreground" />
|
<Layers class="size-3.5 text-muted-foreground" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent class="flex items-center gap-4">
|
||||||
<div class="text-2xl font-bold">{{ workItems.totalEpics }}</div>
|
<div class="flex flex-col items-center">
|
||||||
<p class="text-xs text-muted-foreground">{{ t('dashboard.epicsSubtitle') }}</p>
|
<span class="text-xl font-bold">{{ workItems.totalEpics }}</span>
|
||||||
</CardContent>
|
<span class="text-[10px] text-muted-foreground">{{ t('dashboard.epics') }}</span>
|
||||||
</Card>
|
</div>
|
||||||
|
<div class="flex flex-col items-center">
|
||||||
<Card id="dashboard-stats-hus" class="bg-gradient-to-t from-primary/5 to-card shadow-xs dark:bg-card">
|
<span id="dashboard-stats-hus-count" class="text-xl font-bold">{{ workItems.totalHUs }}</span>
|
||||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
<span class="text-[10px] text-muted-foreground">HUs</span>
|
||||||
<CardTitle class="text-sm font-medium">{{ t('dashboard.hus') }}</CardTitle>
|
</div>
|
||||||
<FileText class="size-4 text-muted-foreground" />
|
<div class="flex flex-col items-center">
|
||||||
</CardHeader>
|
<span class="text-xl font-bold">{{ workItems.inProgressHUs }}</span>
|
||||||
<CardContent>
|
<span class="text-[10px] text-muted-foreground">En prog.</span>
|
||||||
<div id="dashboard-stats-hus-count" class="text-2xl font-bold">{{ workItems.totalHUs }}</div>
|
</div>
|
||||||
<p class="text-xs text-muted-foreground">{{ t('dashboard.husSubtitle') }}</p>
|
<div class="flex-1 h-10 flex items-end gap-0.5">
|
||||||
</CardContent>
|
<div v-for="(val, i) in [workItems.totalEpics, workItems.totalHUs, workItems.inProgressHUs]" :key="i"
|
||||||
</Card>
|
class="flex-1 rounded-t-sm"
|
||||||
|
:class="['bg-primary/20', 'bg-primary/40', 'bg-primary/60'][i]"
|
||||||
<Card id="dashboard-stats-inprogress" class="bg-gradient-to-t from-primary/5 to-card shadow-xs dark:bg-card">
|
:style="{ height: Math.max(8, (val / Math.max(workItems.totalHUs, 1)) * 100) + '%' }"
|
||||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
/>
|
||||||
<CardTitle class="text-sm font-medium">{{ t('dashboard.inProgress') }}</CardTitle>
|
</div>
|
||||||
<Activity class="size-4 text-muted-foreground" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div class="text-2xl font-bold">{{ workItems.inProgressHUs }}</div>
|
|
||||||
<p class="text-xs text-muted-foreground">{{ t('dashboard.activeHus') }}</p>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card id="dashboard-stats-qa" class="bg-gradient-to-t from-primary/5 to-card shadow-xs dark:bg-card">
|
<Card id="dashboard-stats-qa" class="bg-gradient-to-t from-primary/5 to-card shadow-xs dark:bg-card">
|
||||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle class="text-sm font-medium">QA</CardTitle>
|
<CardTitle class="text-xs font-medium">QA</CardTitle>
|
||||||
<CheckCircle2 class="size-4 text-muted-foreground" />
|
<CheckCircle2 class="size-3.5 text-muted-foreground" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div class="text-2xl font-bold">{{ qaMetrics.total }}</div>
|
<div class="text-xl font-bold">{{ qaMetrics.total }}</div>
|
||||||
<p class="text-xs text-muted-foreground">Planes QA · {{ qaMetrics.totalCases }} casos</p>
|
<p class="text-[10px] text-muted-foreground">Planes QA · {{ qaMetrics.totalCases }} casos</p>
|
||||||
<div class="flex gap-2 mt-1.5 text-[10px]">
|
<div class="flex gap-2 mt-1 text-[10px]">
|
||||||
<span class="text-green-600 dark:text-green-400">{{ qaMetrics.auto }} auto</span>
|
<span class="text-green-600 dark:text-green-400">{{ qaMetrics.auto }} auto</span>
|
||||||
<span class="text-amber-600 dark:text-amber-400">{{ qaMetrics.parcial }} parcial</span>
|
<span class="text-amber-600 dark:text-amber-400">{{ qaMetrics.parcial }} parcial</span>
|
||||||
<span class="text-red-600 dark:text-red-400">{{ qaMetrics.manual }} manual</span>
|
<span class="text-red-600 dark:text-red-400">{{ qaMetrics.manual }} manual</span>
|
||||||
@@ -655,12 +711,22 @@ const statusLabel = (status: unknown) => {
|
|||||||
|
|
||||||
<Card id="dashboard-stats-sessions" class="bg-gradient-to-t from-primary/5 to-card shadow-xs dark:bg-card">
|
<Card id="dashboard-stats-sessions" class="bg-gradient-to-t from-primary/5 to-card shadow-xs dark:bg-card">
|
||||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle class="text-sm font-medium">{{ t('dashboard.sessions') }}</CardTitle>
|
<CardTitle class="text-xs font-medium">{{ t('dashboard.sessions') }}</CardTitle>
|
||||||
<Clock class="size-4 text-muted-foreground" />
|
<Clock class="size-3.5 text-muted-foreground" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div class="text-2xl font-bold">{{ workItems.totalSessions }}</div>
|
<div class="text-xl font-bold">{{ workItems.totalSessions }}</div>
|
||||||
<p class="text-xs text-muted-foreground">{{ t('dashboard.sessionsSubtitle') }}</p>
|
<p class="text-[10px] text-muted-foreground">{{ t('dashboard.sessionsSubtitle') }}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card id="dashboard-stats-sc" class="bg-gradient-to-t from-primary/5 to-card shadow-xs dark:bg-card">
|
||||||
|
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle class="text-xs font-medium">Curva S</CardTitle>
|
||||||
|
<TrendingUp class="size-3.5 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="p-2">
|
||||||
|
<SCurveChart v-if="project" :project-id="project.id" :compact="true" />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -668,9 +734,6 @@ const statusLabel = (status: unknown) => {
|
|||||||
<!-- Priorizador Diario -->
|
<!-- Priorizador Diario -->
|
||||||
<PrioritizerCard />
|
<PrioritizerCard />
|
||||||
|
|
||||||
<!-- Curva S -->
|
|
||||||
<SCurveChart v-if="project" :project-id="project.id" />
|
|
||||||
|
|
||||||
<!-- AI Chat -->
|
<!-- AI Chat -->
|
||||||
<AiProjectChat
|
<AiProjectChat
|
||||||
:project-id="project.id"
|
:project-id="project.id"
|
||||||
@@ -988,9 +1051,10 @@ const statusLabel = (status: unknown) => {
|
|||||||
<div
|
<div
|
||||||
v-if="notification"
|
v-if="notification"
|
||||||
class="fixed bottom-4 right-4 z-50 flex items-start gap-3 p-3 rounded-lg border bg-card border-l-[3px] text-xs shadow-lg max-w-sm"
|
class="fixed bottom-4 right-4 z-50 flex items-start gap-3 p-3 rounded-lg border bg-card border-l-[3px] text-xs shadow-lg max-w-sm"
|
||||||
:class="notification.type === 'success' ? 'border-l-green-500' : 'border-l-red-500'"
|
:class="notification.type === 'success' ? 'border-l-green-500' : notification.type === 'error' ? 'border-l-red-500' : 'border-l-blue-500'"
|
||||||
>
|
>
|
||||||
<CheckCircle2 v-if="notification.type === 'success'" class="size-4 text-green-500 shrink-0 mt-0.5" />
|
<CheckCircle2 v-if="notification.type === 'success'" class="size-4 text-green-500 shrink-0 mt-0.5" />
|
||||||
|
<svg v-else-if="notification.type === 'info'" class="size-4 text-blue-500 shrink-0 mt-0.5" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
|
||||||
<XCircle v-else class="size-4 text-red-500 shrink-0 mt-0.5" />
|
<XCircle v-else class="size-4 text-red-500 shrink-0 mt-0.5" />
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<p class="font-medium text-foreground">{{ notification.type === 'success' ? 'Completado' : 'Error' }}</p>
|
<p class="font-medium text-foreground">{{ notification.type === 'success' ? 'Completado' : 'Error' }}</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user