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
+18 -1
View File
@@ -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=="],
+1
View File
@@ -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",
+68 -87
View File
@@ -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>
+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)
}
-8
View File
@@ -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
View File
@@ -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>