K-12 Priorizador diario: HUs vencidas, hoy, esta semana y bloqueadas

- PrioritizerCard.vue: nuevo componente con secciones de priorización
- Tarjetas resumen: total HUs, en progreso, vencidas, bloqueadas
- Listas agrupadas: vencidas (rojo), hoy (ámbar), esta semana, bloqueadas
- Cada item: código, título, prioridad, fecha
- KappaUserStory: agregados end_date, sprint, initial_date
- enrichHU: pasa end_date y sprint al objeto enriquecido
This commit is contained in:
2026-05-29 22:50:38 -05:00
parent 2073d936e2
commit cb0e8067b6
4 changed files with 228 additions and 2 deletions
+218
View File
@@ -0,0 +1,218 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useWorkItemsStore, type EnrichedUserStory } from '@/stores/workitems'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { AlertTriangle, Calendar, Clock, ListChecks } from 'lucide-vue-next'
const { t } = useI18n()
const workItems = useWorkItemsStore()
const today = new Date()
today.setHours(0, 0, 0, 0)
function daysUntil(dateStr: string | undefined): number | null {
if (!dateStr) return null
const d = new Date(dateStr)
d.setHours(0, 0, 0, 0)
return Math.ceil((d.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
}
function isOverdue(dateStr: string | undefined): boolean {
const d = daysUntil(dateStr)
return d !== null && d < 0
}
function isToday(dateStr: string | undefined): boolean {
const d = daysUntil(dateStr)
return d === 0
}
function isThisWeek(dateStr: string | undefined): boolean {
const d = daysUntil(dateStr)
return d !== null && d > 0 && d <= 7
}
function isNextWeek(dateStr: string | undefined): boolean {
const d = daysUntil(dateStr)
return d !== null && d > 7 && d <= 14
}
function isBlocked(hu: EnrichedUserStory): boolean {
const s = String(hu.status ?? '').toLowerCase()
return ['blocked', 'bloqueado', '6'].includes(s)
}
function isInProgress(hu: EnrichedUserStory): boolean {
const s = String(hu.status ?? '').toLowerCase()
return ['in_progress', 'doing', 'wip', 'active', 'in progress', 'en progreso', 'true', '2'].includes(s)
}
// Agrupar HUs
const overdueHUs = computed(() =>
workItems.userStories.filter(hu => hu.end_date && isOverdue(hu.end_date))
)
const todayHUs = computed(() =>
workItems.userStories.filter(hu => hu.end_date && isToday(hu.end_date))
)
const thisWeekHUs = computed(() =>
workItems.userStories.filter(hu => hu.end_date && isThisWeek(hu.end_date))
)
const nextWeekHUs = computed(() =>
workItems.userStories.filter(hu => hu.end_date && isNextWeek(hu.end_date))
)
const blockedHUs = computed(() =>
workItems.userStories.filter(hu => isBlocked(hu))
)
const inProgressHUs = computed(() =>
workItems.userStories.filter(hu => isInProgress(hu))
)
function formatDate(dateStr: string | undefined): string {
if (!dateStr) return ''
const d = new Date(dateStr)
return d.toLocaleDateString('es-CO', { day: 'numeric', month: 'short' })
}
function priorityVariant(p: unknown) {
const s = String(p ?? '').toLowerCase().trim()
if (['alta', 'high', 'critical', 'urgente', '1'].includes(s)) return 'destructive'
if (['media', 'medium', 'normal', '2'].includes(s)) return 'default'
if (['baja', 'low', '3'].includes(s)) return 'secondary'
return 'outline'
}
const totalHUs = computed(() => workItems.userStories.length)
const doneHUs = computed(() =>
workItems.userStories.filter(hu => {
const s = String(hu.status ?? '').toLowerCase()
return ['done', 'completed', 'closed', 'finalizado', '5', 'true'].includes(s)
}).length
)
</script>
<template>
<div class="space-y-4">
<!-- Summary cards -->
<div class="grid gap-3 @xl:grid-cols-4">
<Card class="bg-gradient-to-t from-primary/5 to-card shadow-xs">
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-xs font-medium">{{ t('dashboard.hus') }}</CardTitle>
<ListChecks class="size-3.5 text-muted-foreground" />
</CardHeader>
<CardContent>
<div class="text-xl font-bold">{{ totalHUs }}</div>
<p class="text-[10px] text-muted-foreground">{{ doneHUs }} completadas ({{ totalHUs > 0 ? Math.round(doneHUs / totalHUs * 100) : 0 }}%)</p>
</CardContent>
</Card>
<Card class="bg-gradient-to-t from-amber-500/5 to-card shadow-xs">
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-xs font-medium">En progreso</CardTitle>
<Clock class="size-3.5 text-muted-foreground" />
</CardHeader>
<CardContent>
<div class="text-xl font-bold">{{ inProgressHUs.length }}</div>
<p class="text-[10px] text-muted-foreground">activas</p>
</CardContent>
</Card>
<Card class="bg-gradient-to-t from-red-500/5 to-card shadow-xs">
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-xs font-medium">Vencidas</CardTitle>
<AlertTriangle class="size-3.5 text-red-500" />
</CardHeader>
<CardContent>
<div class="text-xl font-bold text-red-500">{{ overdueHUs.length }}</div>
<p class="text-[10px] text-muted-foreground">requieren atención</p>
</CardContent>
</Card>
<Card class="bg-gradient-to-t from-purple-500/5 to-card shadow-xs">
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-xs font-medium">Bloqueadas</CardTitle>
<AlertTriangle class="size-3.5 text-purple-500" />
</CardHeader>
<CardContent>
<div class="text-xl font-bold text-purple-500">{{ blockedHUs.length }}</div>
<p class="text-[10px] text-muted-foreground">con impedimentos</p>
</CardContent>
</Card>
</div>
<!-- Overdue -->
<Card v-if="overdueHUs.length > 0" class="border-l-3 border-l-red-500">
<CardHeader class="pb-2">
<CardTitle class="text-xs font-medium flex items-center gap-2 text-red-600">
<AlertTriangle class="size-3.5" />
Vencidas ({{ overdueHUs.length }})
</CardTitle>
</CardHeader>
<CardContent class="space-y-1">
<div v-for="hu in overdueHUs" :key="hu.id" class="flex items-center gap-2 text-xs py-1 border-b last:border-0">
<span class="font-mono text-[10px] text-muted-foreground w-16">{{ hu.code || `#${hu.id}` }}</span>
<span class="flex-1 truncate">{{ hu._cleanTitle || hu.title }}</span>
<Badge :variant="priorityVariant(hu.priority)" class="text-[10px]">{{ hu.priority }}</Badge>
<span class="text-red-500 font-mono text-[10px] w-16 text-right">{{ formatDate(hu.end_date) }}</span>
</div>
</CardContent>
</Card>
<!-- Today -->
<Card v-if="todayHUs.length > 0" class="border-l-3 border-l-amber-500">
<CardHeader class="pb-2">
<CardTitle class="text-xs font-medium flex items-center gap-2">
<Calendar class="size-3.5" />
Hoy ({{ todayHUs.length }})
</CardTitle>
</CardHeader>
<CardContent class="space-y-1">
<div v-for="hu in todayHUs" :key="hu.id" class="flex items-center gap-2 text-xs py-1 border-b last:border-0">
<span class="font-mono text-[10px] text-muted-foreground w-16">{{ hu.code || `#${hu.id}` }}</span>
<span class="flex-1 truncate">{{ hu._cleanTitle || hu.title }}</span>
<Badge :variant="priorityVariant(hu.priority)" class="text-[10px]">{{ hu.priority }}</Badge>
<Badge variant="outline" class="text-[10px]">Hoy</Badge>
</div>
</CardContent>
</Card>
<!-- This week -->
<Card v-if="thisWeekHUs.length > 0">
<CardHeader class="pb-2">
<CardTitle class="text-xs font-medium flex items-center gap-2">
<Calendar class="size-3.5" />
Esta semana ({{ thisWeekHUs.length }})
</CardTitle>
</CardHeader>
<CardContent class="space-y-1">
<div v-for="hu in thisWeekHUs" :key="hu.id" class="flex items-center gap-2 text-xs py-1 border-b last:border-0">
<span class="font-mono text-[10px] text-muted-foreground w-16">{{ hu.code || `#${hu.id}` }}</span>
<span class="flex-1 truncate">{{ hu._cleanTitle || hu.title }}</span>
<Badge :variant="priorityVariant(hu.priority)" class="text-[10px]">{{ hu.priority }}</Badge>
<span class="font-mono text-[10px] text-muted-foreground w-16 text-right">{{ formatDate(hu.end_date) }}</span>
</div>
</CardContent>
</Card>
<!-- Blocked -->
<Card v-if="blockedHUs.length > 0" class="border-l-3 border-l-purple-500">
<CardHeader class="pb-2">
<CardTitle class="text-xs font-medium flex items-center gap-2 text-purple-600">
<AlertTriangle class="size-3.5" />
Bloqueadas ({{ blockedHUs.length }})
</CardTitle>
</CardHeader>
<CardContent class="space-y-1">
<div v-for="hu in blockedHUs" :key="hu.id" class="flex items-center gap-2 text-xs py-1 border-b last:border-0">
<span class="font-mono text-[10px] text-muted-foreground w-16">{{ hu.code || `#${hu.id}` }}</span>
<span class="flex-1 truncate">{{ hu._cleanTitle || hu.title }}</span>
<Badge variant="destructive" class="text-[10px]">Bloqueada</Badge>
<Badge v-if="hu.has_impairment" variant="outline" class="text-[10px]">Impedimento</Badge>
</div>
</CardContent>
</Card>
</div>
</template>
+3 -1
View File
@@ -93,7 +93,7 @@ export const useWorkItemsStore = defineStore('workitems', () => {
return { id: null, name: '', employeeId: null }
}
function enrichHU(hu: { title?: string; id?: number; description?: string | null; status?: string | number | null; status_name?: string | null; priority?: string | number | null; story_points?: number | null; acceptance_criteria?: string | null; criterios_aceptacion?: string | null; assigned_to?: number | null; asignado_a?: number[] | string[] | null; asignado_a_names?: string[] | string | null; assigned_name?: string }, initiativeId: number): EnrichedUserStory {
function enrichHU(hu: { title?: string; id?: number; description?: string | null; status?: string | number | null; status_name?: string | null; priority?: string | number | null; story_points?: number | null; end_date?: string | null; sprint?: number | string | null; acceptance_criteria?: string | null; criterios_aceptacion?: string | null; assigned_to?: number | null; asignado_a?: number[] | string[] | null; asignado_a_names?: string[] | string | null; assigned_name?: string }, initiativeId: number): EnrichedUserStory {
const h = parseHierarchy(hu.title || '')
const rawCriteria = hu.acceptance_criteria || hu.criterios_aceptacion || ''
const criteriaList = rawCriteria ? parseQuillList(rawCriteria) : []
@@ -104,6 +104,8 @@ export const useWorkItemsStore = defineStore('workitems', () => {
status: String(hu.status ?? ''),
priority: String(hu.priority ?? ''),
story_points: hu.story_points ?? undefined,
end_date: hu.end_date || undefined,
sprint: hu.sprint != null ? String(hu.sprint) : undefined,
description: hu.description || '',
acceptance_criteria: rawCriteria,
criterios_aceptacion: rawCriteria,
+3 -1
View File
@@ -48,7 +48,9 @@ export interface KappaUserStory {
priority?: string | number
initiative: number | string
story_points?: number
sprint?: string
sprint?: number | string
end_date?: string
initial_date?: string | null
created_at?: string
assigned_to?: number | null
asignado_a?: number[] | string[] | null
+4
View File
@@ -11,6 +11,7 @@ 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 { analyzeProjectEpics, analyzeProjectHUs, saveEpicDrafts, saveHUDrafts } from '@/services/project-analyzer'
import { getDrafts, deleteDraft, type HuDraftRecord } from '@/services/hu-drafts-db'
import { kappa } from '@/services/kappa-api'
@@ -663,6 +664,9 @@ const statusLabel = (status: unknown) => {
</Card>
</div>
<!-- Priorizador Diario -->
<PrioritizerCard />
<!-- AI Chat -->
<AiProjectChat
:project-id="project.id"