agregar paginacion a /userstorys/ + redisenar dashboard de proyecto con epicas y HUs

This commit is contained in:
2026-05-27 13:48:07 -05:00
parent 0f26506d54
commit 5ef2b2c8e5
3 changed files with 157 additions and 106 deletions
+17 -3
View File
@@ -99,9 +99,23 @@ class KappaAPI {
return this.request<PaginatedResponse<KappaEmployee>>('GET', `/employees/?page=${page}`)
}
async getUserStories(initiativeId?: number): Promise<KappaUserStory[]> {
const path = initiativeId ? `/userstorys/?initiative=${initiativeId}` : '/userstorys/'
return this.request<KappaUserStory[]>('GET', path)
async getUserStories(initiativeId?: number, page = 1, pageSize = 100): Promise<PaginatedResponse<KappaUserStory>> {
const params = new URLSearchParams()
if (initiativeId) params.set('initiative', String(initiativeId))
params.set('page', String(page))
params.set('page_size', String(pageSize))
return this.request<PaginatedResponse<KappaUserStory>>('GET', `/userstorys/?${params.toString()}`)
}
async getAllUserStories(initiativeId: number): Promise<KappaUserStory[]> {
const first = await this.getUserStories(initiativeId, 1, 100)
const all = [...first.results]
const totalPages = Math.ceil(first.count / 100)
for (let p = 2; p <= totalPages; p++) {
const page = await this.getUserStories(initiativeId, p, 100)
all.push(...page.results)
}
return all
}
async getEpicDevelopment(initiativeId: number, page = 1, pageSize = 50): Promise<PaginatedResponse<KappaEpicDevelopment>> {
+4 -7
View File
@@ -51,18 +51,15 @@ export const useWorkItemsStore = defineStore('workitems', () => {
try {
const [stories, logs, plans, epicData] = await Promise.all([
kappa.getUserStories(id).catch(() => [] as KappaUserStory[]),
kappa.getAllUserStories(id).catch(() => [] as KappaUserStory[]),
kappa.getLogbooks(id).catch(() => [] as KappaLogbookEntry[]),
kappa.getPlannings(id).catch(() => [] as KappaPlanningEntry[]),
kappa.getAllEpicDevelopment(id).catch(() => [] as KappaEpicDevelopment[]),
])
const storiesData = stories as KappaUserStory[] | { results?: KappaUserStory[] }
const logsData = logs as KappaLogbookEntry[] | { results?: KappaLogbookEntry[] }
const plansData = plans as KappaPlanningEntry[] | { results?: KappaPlanningEntry[] }
userStories.value = Array.isArray(storiesData) ? storiesData : (storiesData.results ?? [])
logbooks.value = Array.isArray(logsData) ? logsData : (logsData.results ?? [])
plannings.value = Array.isArray(plansData) ? plansData : (plansData.results ?? [])
userStories.value = stories
logbooks.value = Array.isArray(logs) ? logs : ((logs as any).results ?? [])
plannings.value = Array.isArray(plans) ? plans : ((plans as any).results ?? [])
epics.value = epicData.map(epic => ({
...epic,
+117 -77
View File
@@ -2,9 +2,10 @@
import { computed, watch } from 'vue'
import { useProjectsStore } from '@/stores/projects'
import { useWorkItemsStore } from '@/stores/workitems'
import { Activity, CreditCard, FileText, Users } from 'lucide-vue-next'
import { Activity, FileText, Layers, Clock, ChevronRight } from 'lucide-vue-next'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
@@ -29,42 +30,70 @@ watch(
{ immediate: true }
)
const recentSessions = computed(() => {
return [...workItems.logbooks]
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
.slice(0, 5)
})
const statusVariant = (status: string) => {
const s = status?.toLowerCase() || ''
if (['done', 'completed', 'closed', 'finalizado'].includes(s)) return 'secondary'
if (['in_progress', 'doing', 'wip', 'active', 'in progress', 'en progreso'].includes(s)) return 'default'
if (['blocked', 'bloqueado'].includes(s)) return 'destructive'
return 'outline'
}
const statusLabel = (status: string) => {
const map: Record<string, string> = {
backlog: 'Backlog',
todo: 'Por hacer',
in_progress: 'En progreso',
'in progress': 'En progreso',
doing: 'Haciendo',
wip: 'WIP',
done: 'Hecho',
completed: 'Completado',
blocked: 'Bloqueado',
review: 'Revisión',
testing: 'Pruebas',
}
return map[status?.toLowerCase() || ''] || status || '—'
}
</script>
<template>
<div v-if="project" class="@container/main flex flex-1 flex-col gap-2">
<div class="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
<div>
<div v-if="project" class="@container/main flex flex-1 flex-col gap-4">
<div class="flex flex-col gap-1">
<h1 class="text-2xl font-bold tracking-tight">{{ project.name }}</h1>
<div class="flex items-center gap-2 mt-1">
<div class="flex items-center gap-2">
<Badge v-if="project.key" variant="outline" class="text-xs">{{ project.key }}</Badge>
<Badge v-if="project.status" variant="secondary" class="text-xs capitalize">{{ project.status }}</Badge>
</div>
</div>
<Badge variant="outline" class="text-xs">v0.1.0</Badge>
</div>
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<!-- Stats -->
<div class="grid gap-3 @xl:grid-cols-2 @3xl:grid-cols-4">
<Card 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">Historias de Usuario</CardTitle>
<FileText class="h-4 w-4 text-muted-foreground" />
<CardTitle class="text-sm font-medium">Épicas</CardTitle>
<Layers class="size-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div class="text-2xl font-bold">{{ workItems.totalHUs }}</div>
<p class="text-xs text-muted-foreground">Total HUs del proyecto</p>
<div class="text-2xl font-bold">{{ workItems.totalEpics }}</div>
<p class="text-xs text-muted-foreground">Actividades del cronograma</p>
</CardContent>
</Card>
<Card>
<Card 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">HUs</CardTitle>
<FileText class="size-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div class="text-2xl font-bold">{{ workItems.totalHUs }}</div>
<p class="text-xs text-muted-foreground">Historias de usuario</p>
</CardContent>
</Card>
<Card 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">En progreso</CardTitle>
<Activity class="h-4 w-4 text-muted-foreground" />
<Activity class="size-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div class="text-2xl font-bold">{{ workItems.inProgressHUs }}</div>
@@ -72,99 +101,110 @@ const recentSessions = computed(() => {
</CardContent>
</Card>
<Card>
<Card 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">Sesiones</CardTitle>
<Users class="h-4 w-4 text-muted-foreground" />
<Clock class="size-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div class="text-2xl font-bold">{{ workItems.totalSessions }}</div>
<p class="text-xs text-muted-foreground">Bitácoras registradas</p>
</CardContent>
</Card>
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">Planeaciones</CardTitle>
<CreditCard class="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div class="text-2xl font-bold">{{ workItems.plannings.length }}</div>
<p class="text-xs text-muted-foreground">Entradas de planeación</p>
<p class="text-xs text-muted-foreground">Bitácoras</p>
</CardContent>
</Card>
</div>
<div class="grid gap-4 md:grid-cols-2">
<!-- Description -->
<Card>
<CardHeader>
<CardTitle class="text-sm font-medium">Historia clínica</CardTitle>
<CardHeader class="pb-2">
<CardTitle class="text-sm font-medium">Descripción</CardTitle>
</CardHeader>
<CardContent>
<p v-if="project.description" class="text-sm text-muted-foreground leading-relaxed">
<p v-if="project.description" class="text-sm text-muted-foreground leading-relaxed line-clamp-4">
{{ project.description }}
</p>
<p v-else class="text-sm text-muted-foreground/50 italic">Sin descripción</p>
<div v-if="project.start_date" class="mt-4 space-y-2">
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">Ingreso</span>
<span class="font-medium">{{ project.start_date }}</span>
</div>
<div v-if="project.end_date" class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">Alta prevista</span>
<span class="font-medium">{{ project.end_date }}</span>
</div>
<div v-if="project.start_date || project.end_date" class="flex items-center gap-4 mt-3 text-xs text-muted-foreground">
<span v-if="project.start_date">📅 {{ project.start_date }}</span>
<span v-if="project.end_date"> {{ project.end_date }}</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle class="text-sm font-medium">Actividad reciente</CardTitle>
<!-- Loading -->
<template v-if="workItems.loading">
<Skeleton class="h-8 w-1/3" />
<div class="space-y-2">
<Skeleton v-for="i in 4" :key="i" class="h-12 w-full" />
</div>
</template>
<!-- Epics -->
<template v-else-if="workItems.epics.length > 0">
<div>
<h3 class="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-3">
Épicas · {{ workItems.totalEpics }}
</h3>
<div class="space-y-2">
<Card
v-for="epic in workItems.epics"
:key="epic.id"
class="bg-gradient-to-t from-primary/5 to-card shadow-xs dark:bg-card"
>
<CardHeader class="p-4 pb-2">
<div class="flex items-start justify-between gap-2">
<div class="flex items-center gap-2 min-w-0">
<span class="font-mono text-xs text-muted-foreground flex-shrink-0">
{{ epic.code || `EP-${epic.id}` }}
</span>
<CardTitle class="text-sm truncate">{{ epic.name || epic.title || `Épica ${epic.id}` }}</CardTitle>
</div>
<Badge :variant="statusVariant(epic.status || '')" class="text-xs flex-shrink-0">
{{ statusLabel(epic.status || '') }}
</Badge>
</div>
</CardHeader>
<CardContent>
<div v-if="recentSessions.length > 0" class="space-y-4">
<div v-for="session in recentSessions" :key="session.id" class="flex items-center gap-4">
<div class="h-2 w-2 rounded-full bg-muted-foreground/30" />
<div class="flex-1 space-y-1">
<p class="text-sm font-medium leading-none truncate">{{ session.description?.slice(0, 50) || 'Sin descripción' }}</p>
<p class="text-xs text-muted-foreground">
{{ new Date(session.date).toLocaleDateString('es-CO', { month: 'long', day: 'numeric', year: 'numeric' }) }}
<span v-if="session.hours"> · {{ session.hours }}h</span>
<CardContent v-if="epic.description" class="p-4 pt-0">
<p class="text-xs text-muted-foreground line-clamp-2">
{{ epic.description }}
</p>
</div>
</div>
</div>
<p v-else class="text-xs text-muted-foreground/50 italic">Sin actividad reciente</p>
</CardContent>
</Card>
</div>
</div>
</template>
<!-- HUs Table -->
<Card>
<CardHeader class="flex flex-row items-center justify-between">
<CardTitle class="text-sm font-medium">Work Items</CardTitle>
<Badge v-if="workItems.loading" variant="outline" class="text-[10px]">Cargando...</Badge>
<Badge v-else variant="outline" class="text-[10px]">{{ workItems.userStories.length }} HUs</Badge>
<CardHeader class="flex flex-row items-center justify-between pb-2">
<CardTitle class="text-sm font-medium">Historias de Usuario</CardTitle>
<Badge variant="outline" class="text-xs">{{ workItems.userStories.length }} HUs</Badge>
</CardHeader>
<CardContent>
<Table v-if="workItems.userStories.length > 0">
<TableHeader>
<TableRow>
<TableHead>Código</TableHead>
<TableHead class="w-[100px]">Código</TableHead>
<TableHead>Título</TableHead>
<TableHead>Estado</TableHead>
<TableHead class="text-right">Prioridad</TableHead>
<TableHead class="w-[110px]">Estado</TableHead>
<TableHead class="w-[90px] text-right">Prioridad</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="story in workItems.userStories.slice(0, 10)" :key="story.id">
<TableCell class="font-mono text-xs text-muted-foreground">{{ story.code || `HU-${story.id}` }}</TableCell>
<TableCell class="text-sm truncate max-w-[200px]">{{ story.title }}</TableCell>
<TableCell>
<Badge variant="outline" class="text-[10px] capitalize">{{ story.status || 'backlog' }}</Badge>
<TableRow v-for="hu in workItems.userStories" :key="hu.id">
<TableCell class="font-mono text-xs text-muted-foreground">
{{ hu.code || `HU-${hu.id}` }}
</TableCell>
<TableCell class="text-sm max-w-[300px] truncate">
{{ hu.title }}
</TableCell>
<TableCell>
<Badge :variant="statusVariant(hu.status || '')" class="text-xs capitalize">
{{ statusLabel(hu.status || '') }}
</Badge>
</TableCell>
<TableCell class="text-right text-xs text-muted-foreground">
{{ hu.priority || '—' }}
</TableCell>
<TableCell class="text-right text-muted-foreground/70">{{ story.priority || '—' }}</TableCell>
</TableRow>
</TableBody>
</Table>