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