agregar paginacion a /userstorys/ + redisenar dashboard de proyecto con epicas y HUs
This commit is contained in:
@@ -99,9 +99,23 @@ class KappaAPI {
|
|||||||
return this.request<PaginatedResponse<KappaEmployee>>('GET', `/employees/?page=${page}`)
|
return this.request<PaginatedResponse<KappaEmployee>>('GET', `/employees/?page=${page}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUserStories(initiativeId?: number): Promise<KappaUserStory[]> {
|
async getUserStories(initiativeId?: number, page = 1, pageSize = 100): Promise<PaginatedResponse<KappaUserStory>> {
|
||||||
const path = initiativeId ? `/userstorys/?initiative=${initiativeId}` : '/userstorys/'
|
const params = new URLSearchParams()
|
||||||
return this.request<KappaUserStory[]>('GET', path)
|
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>> {
|
async getEpicDevelopment(initiativeId: number, page = 1, pageSize = 50): Promise<PaginatedResponse<KappaEpicDevelopment>> {
|
||||||
|
|||||||
@@ -51,18 +51,15 @@ export const useWorkItemsStore = defineStore('workitems', () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const [stories, logs, plans, epicData] = await Promise.all([
|
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.getLogbooks(id).catch(() => [] as KappaLogbookEntry[]),
|
||||||
kappa.getPlannings(id).catch(() => [] as KappaPlanningEntry[]),
|
kappa.getPlannings(id).catch(() => [] as KappaPlanningEntry[]),
|
||||||
kappa.getAllEpicDevelopment(id).catch(() => [] as KappaEpicDevelopment[]),
|
kappa.getAllEpicDevelopment(id).catch(() => [] as KappaEpicDevelopment[]),
|
||||||
])
|
])
|
||||||
|
|
||||||
const storiesData = stories as KappaUserStory[] | { results?: KappaUserStory[] }
|
userStories.value = stories
|
||||||
const logsData = logs as KappaLogbookEntry[] | { results?: KappaLogbookEntry[] }
|
logbooks.value = Array.isArray(logs) ? logs : ((logs as any).results ?? [])
|
||||||
const plansData = plans as KappaPlanningEntry[] | { results?: KappaPlanningEntry[] }
|
plannings.value = Array.isArray(plans) ? plans : ((plans as any).results ?? [])
|
||||||
userStories.value = Array.isArray(storiesData) ? storiesData : (storiesData.results ?? [])
|
|
||||||
logbooks.value = Array.isArray(logsData) ? logsData : (logsData.results ?? [])
|
|
||||||
plannings.value = Array.isArray(plansData) ? plansData : (plansData.results ?? [])
|
|
||||||
|
|
||||||
epics.value = epicData.map(epic => ({
|
epics.value = epicData.map(epic => ({
|
||||||
...epic,
|
...epic,
|
||||||
|
|||||||
+117
-77
@@ -2,9 +2,10 @@
|
|||||||
import { computed, watch } from 'vue'
|
import { computed, watch } from 'vue'
|
||||||
import { useProjectsStore } from '@/stores/projects'
|
import { useProjectsStore } from '@/stores/projects'
|
||||||
import { useWorkItemsStore } from '@/stores/workitems'
|
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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -29,42 +30,70 @@ watch(
|
|||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
const recentSessions = computed(() => {
|
const statusVariant = (status: string) => {
|
||||||
return [...workItems.logbooks]
|
const s = status?.toLowerCase() || ''
|
||||||
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
|
if (['done', 'completed', 'closed', 'finalizado'].includes(s)) return 'secondary'
|
||||||
.slice(0, 5)
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="project" class="@container/main flex flex-1 flex-col gap-2">
|
<div v-if="project" class="@container/main flex flex-1 flex-col gap-4">
|
||||||
<div class="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
<div class="flex flex-col gap-1">
|
||||||
<div>
|
|
||||||
<h1 class="text-2xl font-bold tracking-tight">{{ project.name }}</h1>
|
<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.key" variant="outline" class="text-xs">{{ project.key }}</Badge>
|
||||||
<Badge v-if="project.status" variant="secondary" class="text-xs capitalize">{{ project.status }}</Badge>
|
<Badge v-if="project.status" variant="secondary" class="text-xs capitalize">{{ project.status }}</Badge>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<!-- Stats -->
|
||||||
<Card>
|
<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">
|
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle class="text-sm font-medium">Historias de Usuario</CardTitle>
|
<CardTitle class="text-sm font-medium">Épicas</CardTitle>
|
||||||
<FileText class="h-4 w-4 text-muted-foreground" />
|
<Layers class="size-4 text-muted-foreground" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div class="text-2xl font-bold">{{ workItems.totalHUs }}</div>
|
<div class="text-2xl font-bold">{{ workItems.totalEpics }}</div>
|
||||||
<p class="text-xs text-muted-foreground">Total HUs del proyecto</p>
|
<p class="text-xs text-muted-foreground">Actividades del cronograma</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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">
|
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle class="text-sm font-medium">En progreso</CardTitle>
|
<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>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div class="text-2xl font-bold">{{ workItems.inProgressHUs }}</div>
|
<div class="text-2xl font-bold">{{ workItems.inProgressHUs }}</div>
|
||||||
@@ -72,99 +101,110 @@ const recentSessions = computed(() => {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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">
|
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle class="text-sm font-medium">Sesiones</CardTitle>
|
<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>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div class="text-2xl font-bold">{{ workItems.totalSessions }}</div>
|
<div class="text-2xl font-bold">{{ workItems.totalSessions }}</div>
|
||||||
<p class="text-xs text-muted-foreground">Bitácoras registradas</p>
|
<p class="text-xs text-muted-foreground">Bitácoras</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>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-4 md:grid-cols-2">
|
<!-- Description -->
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader class="pb-2">
|
||||||
<CardTitle class="text-sm font-medium">Historia clínica</CardTitle>
|
<CardTitle class="text-sm font-medium">Descripción</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<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 }}
|
{{ project.description }}
|
||||||
</p>
|
</p>
|
||||||
<p v-else class="text-sm text-muted-foreground/50 italic">Sin descripción</p>
|
<p v-else class="text-sm text-muted-foreground/50 italic">Sin descripción</p>
|
||||||
|
<div v-if="project.start_date || project.end_date" class="flex items-center gap-4 mt-3 text-xs text-muted-foreground">
|
||||||
<div v-if="project.start_date" class="mt-4 space-y-2">
|
<span v-if="project.start_date">📅 {{ project.start_date }}</span>
|
||||||
<div class="flex items-center justify-between text-sm">
|
<span v-if="project.end_date">→ {{ project.end_date }}</span>
|
||||||
<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>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<!-- Loading -->
|
||||||
<CardHeader>
|
<template v-if="workItems.loading">
|
||||||
<CardTitle class="text-sm font-medium">Actividad reciente</CardTitle>
|
<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>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent v-if="epic.description" class="p-4 pt-0">
|
||||||
<div v-if="recentSessions.length > 0" class="space-y-4">
|
<p class="text-xs text-muted-foreground line-clamp-2">
|
||||||
<div v-for="session in recentSessions" :key="session.id" class="flex items-center gap-4">
|
{{ epic.description }}
|
||||||
<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>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p v-else class="text-xs text-muted-foreground/50 italic">Sin actividad reciente</p>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- HUs Table -->
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader class="flex flex-row items-center justify-between">
|
<CardHeader class="flex flex-row items-center justify-between pb-2">
|
||||||
<CardTitle class="text-sm font-medium">Work Items</CardTitle>
|
<CardTitle class="text-sm font-medium">Historias de Usuario</CardTitle>
|
||||||
<Badge v-if="workItems.loading" variant="outline" class="text-[10px]">Cargando...</Badge>
|
<Badge variant="outline" class="text-xs">{{ workItems.userStories.length }} HUs</Badge>
|
||||||
<Badge v-else variant="outline" class="text-[10px]">{{ workItems.userStories.length }} HUs</Badge>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Table v-if="workItems.userStories.length > 0">
|
<Table v-if="workItems.userStories.length > 0">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Código</TableHead>
|
<TableHead class="w-[100px]">Código</TableHead>
|
||||||
<TableHead>Título</TableHead>
|
<TableHead>Título</TableHead>
|
||||||
<TableHead>Estado</TableHead>
|
<TableHead class="w-[110px]">Estado</TableHead>
|
||||||
<TableHead class="text-right">Prioridad</TableHead>
|
<TableHead class="w-[90px] text-right">Prioridad</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
<TableRow v-for="story in workItems.userStories.slice(0, 10)" :key="story.id">
|
<TableRow v-for="hu in workItems.userStories" :key="hu.id">
|
||||||
<TableCell class="font-mono text-xs text-muted-foreground">{{ story.code || `HU-${story.id}` }}</TableCell>
|
<TableCell class="font-mono text-xs text-muted-foreground">
|
||||||
<TableCell class="text-sm truncate max-w-[200px]">{{ story.title }}</TableCell>
|
{{ hu.code || `HU-${hu.id}` }}
|
||||||
<TableCell>
|
</TableCell>
|
||||||
<Badge variant="outline" class="text-[10px] capitalize">{{ story.status || 'backlog' }}</Badge>
|
<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>
|
||||||
<TableCell class="text-right text-muted-foreground/70">{{ story.priority || '—' }}</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|||||||
Reference in New Issue
Block a user