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:
@@ -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>
|
||||||
@@ -93,7 +93,7 @@ export const useWorkItemsStore = defineStore('workitems', () => {
|
|||||||
return { id: null, name: '', employeeId: null }
|
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 h = parseHierarchy(hu.title || '')
|
||||||
const rawCriteria = hu.acceptance_criteria || hu.criterios_aceptacion || ''
|
const rawCriteria = hu.acceptance_criteria || hu.criterios_aceptacion || ''
|
||||||
const criteriaList = rawCriteria ? parseQuillList(rawCriteria) : []
|
const criteriaList = rawCriteria ? parseQuillList(rawCriteria) : []
|
||||||
@@ -104,6 +104,8 @@ export const useWorkItemsStore = defineStore('workitems', () => {
|
|||||||
status: String(hu.status ?? ''),
|
status: String(hu.status ?? ''),
|
||||||
priority: String(hu.priority ?? ''),
|
priority: String(hu.priority ?? ''),
|
||||||
story_points: hu.story_points ?? undefined,
|
story_points: hu.story_points ?? undefined,
|
||||||
|
end_date: hu.end_date || undefined,
|
||||||
|
sprint: hu.sprint != null ? String(hu.sprint) : undefined,
|
||||||
description: hu.description || '',
|
description: hu.description || '',
|
||||||
acceptance_criteria: rawCriteria,
|
acceptance_criteria: rawCriteria,
|
||||||
criterios_aceptacion: rawCriteria,
|
criterios_aceptacion: rawCriteria,
|
||||||
|
|||||||
+3
-1
@@ -48,7 +48,9 @@ export interface KappaUserStory {
|
|||||||
priority?: string | number
|
priority?: string | number
|
||||||
initiative: number | string
|
initiative: number | string
|
||||||
story_points?: number
|
story_points?: number
|
||||||
sprint?: string
|
sprint?: number | string
|
||||||
|
end_date?: string
|
||||||
|
initial_date?: string | null
|
||||||
created_at?: string
|
created_at?: string
|
||||||
assigned_to?: number | null
|
assigned_to?: number | null
|
||||||
asignado_a?: number[] | string[] | null
|
asignado_a?: number[] | string[] | null
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||||
import HuDrafts from '@/components/HuDrafts.vue'
|
import HuDrafts from '@/components/HuDrafts.vue'
|
||||||
import AiProjectChat from '@/components/AiProjectChat.vue'
|
import AiProjectChat from '@/components/AiProjectChat.vue'
|
||||||
|
import PrioritizerCard from '@/components/PrioritizerCard.vue'
|
||||||
import { analyzeProjectEpics, analyzeProjectHUs, saveEpicDrafts, saveHUDrafts } from '@/services/project-analyzer'
|
import { analyzeProjectEpics, analyzeProjectHUs, saveEpicDrafts, saveHUDrafts } from '@/services/project-analyzer'
|
||||||
import { getDrafts, deleteDraft, type HuDraftRecord } from '@/services/hu-drafts-db'
|
import { getDrafts, deleteDraft, type HuDraftRecord } from '@/services/hu-drafts-db'
|
||||||
import { kappa } from '@/services/kappa-api'
|
import { kappa } from '@/services/kappa-api'
|
||||||
@@ -663,6 +664,9 @@ const statusLabel = (status: unknown) => {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Priorizador Diario -->
|
||||||
|
<PrioritizerCard />
|
||||||
|
|
||||||
<!-- AI Chat -->
|
<!-- AI Chat -->
|
||||||
<AiProjectChat
|
<AiProjectChat
|
||||||
:project-id="project.id"
|
:project-id="project.id"
|
||||||
|
|||||||
Reference in New Issue
Block a user