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
+68 -87
View File
@@ -1,13 +1,10 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { useWorkItemsStore } from '@/stores/workitems'
import { calculateSCurve, type CurvePoint, type SCurveMetrics, type SCurveData } from '@/services/s-curve'
import { getBlockers, getBlockerStats } 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'
import { calculateSCurve, type CurvePoint, type SCurveData } from '@/services/s-curve'
import { getBlockers } from '@/services/blocker-log'
const props = defineProps<{ projectId: number }>()
const props = defineProps<{ projectId: number; compact?: boolean }>()
const workItems = useWorkItemsStore()
const loading = ref(true)
@@ -28,23 +25,31 @@ async function load() {
onMounted(load)
watch(() => workItems.userStories.length, load)
const CHART_W = 600
const CHART_H = 250
const PAD = { top: 20, right: 20, bottom: 35, left: 50 }
const CHART_W = 400
const CHART_H = 150
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 ''
const w = CHART_W - PAD.left - PAD.right
const h = CHART_H - PAD.top - PAD.bottom
const firstDate = points[0].date
return points.map((p, i) => {
const first = new Date(firstDate)
const cur = new Date(p.date)
const dx = Math.max(0, Math.ceil((cur.getTime() - first.getTime()) / (1000 * 60 * 60 * 24)))
const line = points.map((p, i) => {
const dx = dayOffset(p.date, firstDate)
const x = PAD.left + (dx / totalDays) * w
const y = PAD.top + h - (p.cumulative / maxSp) * h
return `${i === 0 ? 'M' : 'L'}${x},${y}`
}).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 || [])
@@ -66,84 +71,60 @@ const totalDays = computed(() => {
</script>
<template>
<Card id="scurve-chart">
<CardHeader class="pb-3">
<CardTitle class="text-sm font-medium flex items-center gap-2">
<TrendingUp class="size-4" />
Curva S del proyecto
</CardTitle>
</CardHeader>
<CardContent>
<div v-if="loading" class="text-xs text-muted-foreground text-center py-8">Cargando...</div>
<div v-if="loading" class="text-[10px] text-muted-foreground text-center py-4">Cargando...</div>
<div v-else-if="planned.length === 0" class="text-[10px] text-muted-foreground text-center py-4">Sin datos</div>
<div v-else class="space-y-2">
<!-- Mini metrics row for compact -->
<div v-if="metrics && compact" class="flex items-center gap-2 text-[10px] text-muted-foreground px-1">
<span class="font-mono font-bold text-foreground">{{ metrics.totalSpPlanned }} SP</span>
<span class="text-muted-foreground/50">|</span>
<span :class="metrics.spi >= 1 ? 'text-green-500' : 'text-red-500'">SPI {{ metrics.spi }}</span>
</div>
<div v-else-if="planned.length === 0" class="text-xs text-muted-foreground text-center py-8">
No hay suficientes datos. Se necesitan HUs con story points y fechas.
</div>
<!-- SVG Chart -->
<svg :viewBox="`0 0 ${CHART_W} ${CHART_H}`" class="w-full h-auto">
<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">
<!-- Metrics row -->
<div v-if="metrics" class="grid grid-cols-4 gap-2 text-xs">
<div class="p-2 rounded-lg bg-muted/30">
<span class="text-muted-foreground">SP total</span>
<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>
<!-- Grid -->
<line v-for="i in 3" :key="i"
:x1="PAD.left" :y1="PAD.top + ((i-1)/2) * (CHART_H - PAD.top - PAD.bottom)"
:x2="CHART_W - PAD.right" :y2="PAD.top + ((i-1)/2) * (CHART_H - PAD.top - PAD.bottom)"
stroke="currentColor" stroke-opacity="0.08" stroke-dasharray="3,3"
/>
<!-- Blockers summary -->
<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">
<AlertTriangle class="size-3.5 text-orange-500 shrink-0" />
<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>
<!-- Area fills -->
<path :d="buildAreaPath(planned, maxSp, totalDays)" fill="url(#planGrad)" />
<path :d="buildAreaPath(actual, maxSp, totalDays)" fill="url(#actualGrad)" />
<!-- SVG Chart -->
<svg :viewBox="`0 0 ${CHART_W} ${CHART_H}`" class="w-full h-auto">
<line v-for="i in 5" :key="i"
: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" />
<!-- Lines -->
<path :d="buildAreaPath(planned, maxSp, totalDays).replace(/Z$/, '')" fill="none" stroke="#3b82f6" stroke-width="1.5" />
<path :d="buildAreaPath(actual, maxSp, totalDays).replace(/Z$/, '')" fill="none" stroke="#22c55e" stroke-width="1.5" />
<text v-for="i in 5" :key="'y'+i"
:x="PAD.left - 8"
:y="PAD.top + ((i-1)/4) * (CHART_H - PAD.top - PAD.bottom) + 4"
text-anchor="end" class="fill-muted-foreground text-[10px]"
>{{ Math.round(maxSp * (i-1) / 4) }}</text>
<!-- Y labels -->
<text v-for="i in 3" :key="'y'+i"
:x="PAD.left - 4"
:y="PAD.top + ((i-1)/2) * (CHART_H - PAD.top - PAD.bottom) + 3"
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]">
{{ planned[0]?.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>
<!-- X labels -->
<text :x="PAD.left" :y="CHART_H - 4" text-anchor="middle" class="fill-muted-foreground text-[8px]">{{ planned[0]?.date?.slice(5) || '' }}</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>
<line x1="20" y1="12" x2="40" y2="12" stroke="#3b82f6" stroke-width="2" />
<text x="45" y="16" class="fill-muted-foreground text-[9px]">Plan</text>
<line x1="90" y1="12" x2="110" y2="12" stroke="#22c55e" stroke-width="2" />
<text x="115" y="16" class="fill-muted-foreground text-[9px]">Real</text>
</svg>
</div>
</CardContent>
</Card>
<!-- Mini legend -->
<circle cx="20" cy="8" r="3" fill="#3b82f6" />
<text x="27" y="12" class="fill-muted-foreground text-[8px]">Plan</text>
<circle cx="65" cy="8" r="3" fill="#22c55e" />
<text x="72" y="12" class="fill-muted-foreground text-[8px]">Real</text>
</svg>
</div>
</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
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,
+109 -45
View File
@@ -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<typeof setTimeout> | 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) => {
</h1>
<div class="flex items-center gap-2">
<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>
<!-- Stats -->
<div id="dashboard-stats" class="grid gap-3 @xl:grid-cols-2 @3xl:grid-cols-5">
<!-- Stats: 4 cards -->
<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">
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">{{ t('dashboard.epics') }}</CardTitle>
<Layers class="size-4 text-muted-foreground" />
<CardTitle class="text-xs font-medium">{{ t('dashboard.epics') }} · {{ t('dashboard.hus') }} · {{ t('dashboard.inProgress') }}</CardTitle>
<Layers class="size-3.5 text-muted-foreground" />
</CardHeader>
<CardContent>
<div class="text-2xl font-bold">{{ workItems.totalEpics }}</div>
<p class="text-xs text-muted-foreground">{{ t('dashboard.epicsSubtitle') }}</p>
</CardContent>
</Card>
<Card id="dashboard-stats-hus" 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-sm font-medium">{{ t('dashboard.hus') }}</CardTitle>
<FileText class="size-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div id="dashboard-stats-hus-count" class="text-2xl font-bold">{{ workItems.totalHUs }}</div>
<p class="text-xs text-muted-foreground">{{ t('dashboard.husSubtitle') }}</p>
</CardContent>
</Card>
<Card id="dashboard-stats-inprogress" 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-sm font-medium">{{ t('dashboard.inProgress') }}</CardTitle>
<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 class="flex items-center gap-4">
<div class="flex flex-col items-center">
<span class="text-xl font-bold">{{ workItems.totalEpics }}</span>
<span class="text-[10px] text-muted-foreground">{{ t('dashboard.epics') }}</span>
</div>
<div class="flex flex-col items-center">
<span id="dashboard-stats-hus-count" class="text-xl font-bold">{{ workItems.totalHUs }}</span>
<span class="text-[10px] text-muted-foreground">HUs</span>
</div>
<div class="flex flex-col items-center">
<span class="text-xl font-bold">{{ workItems.inProgressHUs }}</span>
<span class="text-[10px] text-muted-foreground">En prog.</span>
</div>
<div class="flex-1 h-10 flex items-end gap-0.5">
<div v-for="(val, i) in [workItems.totalEpics, workItems.totalHUs, workItems.inProgressHUs]" :key="i"
class="flex-1 rounded-t-sm"
:class="['bg-primary/20', 'bg-primary/40', 'bg-primary/60'][i]"
:style="{ height: Math.max(8, (val / Math.max(workItems.totalHUs, 1)) * 100) + '%' }"
/>
</div>
</CardContent>
</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">
<CardTitle class="text-sm font-medium">QA</CardTitle>
<CheckCircle2 class="size-4 text-muted-foreground" />
<CardTitle class="text-xs font-medium">QA</CardTitle>
<CheckCircle2 class="size-3.5 text-muted-foreground" />
</CardHeader>
<CardContent>
<div class="text-2xl font-bold">{{ qaMetrics.total }}</div>
<p class="text-xs text-muted-foreground">Planes QA · {{ qaMetrics.totalCases }} casos</p>
<div class="flex gap-2 mt-1.5 text-[10px]">
<div class="text-xl font-bold">{{ qaMetrics.total }}</div>
<p class="text-[10px] text-muted-foreground">Planes QA · {{ qaMetrics.totalCases }} casos</p>
<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-amber-600 dark:text-amber-400">{{ qaMetrics.parcial }} parcial</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">
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">{{ t('dashboard.sessions') }}</CardTitle>
<Clock class="size-4 text-muted-foreground" />
<CardTitle class="text-xs font-medium">{{ t('dashboard.sessions') }}</CardTitle>
<Clock class="size-3.5 text-muted-foreground" />
</CardHeader>
<CardContent>
<div class="text-2xl font-bold">{{ workItems.totalSessions }}</div>
<p class="text-xs text-muted-foreground">{{ t('dashboard.sessionsSubtitle') }}</p>
<div class="text-xl font-bold">{{ workItems.totalSessions }}</div>
<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>
</Card>
</div>
@@ -668,9 +734,6 @@ const statusLabel = (status: unknown) => {
<!-- Priorizador Diario -->
<PrioritizerCard />
<!-- Curva S -->
<SCurveChart v-if="project" :project-id="project.id" />
<!-- AI Chat -->
<AiProjectChat
:project-id="project.id"
@@ -988,9 +1051,10 @@ const statusLabel = (status: unknown) => {
<div
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="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" />
<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" />
<div class="flex-1 min-w-0">
<p class="font-medium text-foreground">{{ notification.type === 'success' ? 'Completado' : 'Error' }}</p>