DashboardView: tabla de épicas, columna descripción con modal, auto-delete drafts, notificación inline

- Epics reemplazados de cards a tabla con columnas: código, rol, título, descripción (modal), HUs, estado
- HUs tabla: nueva columna Desc con icono ojo que abre modal con descripción completa
- enrichHU ahora incluye description en el objeto
- pushDraft: al recibir 200 OK elimina el draft de Dexie y refresca
- Notificación inline (esquina inferior derecha) reemplaza toast global que no funcionaba en Tauri
- Eliminado useToast / ToastNotification (no funcional en Tauri)
This commit is contained in:
2026-05-29 15:28:59 -05:00
parent 9909f16229
commit a4245017f8
3 changed files with 104 additions and 45 deletions
+2 -1
View File
@@ -89,7 +89,7 @@ export const useWorkItemsStore = defineStore('workitems', () => {
return { id: null, name: '', employeeId: null }
}
function enrichHU(hu: { title?: string; id?: number; 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; 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) : []
@@ -100,6 +100,7 @@ export const useWorkItemsStore = defineStore('workitems', () => {
status: String(hu.status ?? ''),
priority: String(hu.priority ?? ''),
story_points: hu.story_points ?? undefined,
description: hu.description || '',
acceptance_criteria: rawCriteria,
criterios_aceptacion: rawCriteria,
initiative: initiativeId,
+99 -39
View File
@@ -6,12 +6,12 @@ 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 } from 'lucide-vue-next'
import { Activity, FileText, Layers, Clock, Info, AlertTriangle, Plus, Brain, Sparkles, Loader2, CheckCircle2, XCircle, Send, ChevronDown, Eye } 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 { analyzeProject, saveAsDrafts } from '@/services/project-analyzer'
import { useToast } from '@/composables/useToast'
import { getDrafts, deleteDraft, type HuDraftRecord } from '@/services/hu-drafts-db'
import { kappa } from '@/services/kappa-api'
import { generateAndSavePlan, getQAPlans, type HUQAPlan } from '@/services/qa-analyzer'
@@ -37,9 +37,27 @@ const { t } = useI18n()
const projects = useProjectsStore()
const workItems = useWorkItemsStore()
const usersStore = useUsersStore()
const { show: showToast } = useToast()
const project = computed(() => projects.selected)
// ─── Inline notification (funciona en Tauri) ─────────
const notification = ref<{ type: 'success' | 'error'; message: string } | null>(null)
let notifTimeout: ReturnType<typeof setTimeout> | null = null
function showNotif(type: 'success' | 'error', message: string) {
if (notifTimeout) clearTimeout(notifTimeout)
notification.value = { type, message }
notifTimeout = setTimeout(() => { notification.value = null }, 5000)
}
// ─── Description modals ──────────────────────────────
const descModal = ref<{ title: string; content: string } | null>(null)
const descModalOpen = ref(false)
function openDescription(title: string, content: string) {
descModal.value = { title, content }
descModalOpen.value = true
}
// ─── Team members for this project ────────────────────
const projectTeam = computed(() => {
const p = project.value
@@ -311,19 +329,20 @@ async function pushDraft(d: HuDraftRecord) {
const created = await res.json()
d.kappaId = created.id || undefined
}
d.syncStatus = 'pushed'
await dbSaveDraft(d)
showToast('success', d.type === 'E' ? 'Épica creada en KAPPA' : 'HU creada en KAPPA', d.title.slice(0, 100))
await deleteDraft(d.id)
await loadDrafts()
await workItems.fetchWorkItems(project.value!.id)
showNotif('success', `${d.type === 'E' ? 'Épica' : 'HU'} creada en KAPPA: ${d.title.slice(0, 80)}`)
} else {
const errorText = await res.text().catch(() => 'Error desconocido')
console.error(`[Alpha] Error push a KAPPA (${endpoint}): ${res.status}${errorText}`)
showToast('error', 'Error al crear en KAPPA', errorText.slice(0, 300))
showNotif('error', `Error (${res.status}): ${errorText.slice(0, 200)}`)
d.syncStatus = 'draft'
await dbSaveDraft(d)
}
} catch (e: any) {
console.error('[Alpha] Error en pushDraft:', e)
showToast('error', 'Error de red', e.message)
showNotif('error', `Error de red: ${e.message}`)
d.syncStatus = 'draft'
await dbSaveDraft(d)
} finally {
@@ -605,40 +624,49 @@ const statusLabel = (status: unknown) => {
</CardContent>
</Card>
<!-- Epics -->
<template v-if="workItems.epics.length > 0">
<div>
<h3 id="dashboard-epics-heading" class="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-3">
{{ t('dashboard.epicsCount', { count: 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="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold"
:class="getTypeColor(epic._itemType)"
>
{{ getTypeIcon(epic._itemType) }} {{ getTypeLabel(epic._itemType) }}
</span>
<span class="font-mono text-xs text-muted-foreground flex-shrink-0">{{ epic.code || `EP-${epic.id}` }}</span>
<CardTitle class="text-sm truncate">{{ epic._cleanName || epic.name || epic.title || t('dashboard.epicFallback', { id: epic.id }) }}</CardTitle>
</div>
<Badge :variant="statusVariant(epic.status || '')" class="text-xs flex-shrink-0">{{ statusLabel(epic.status || '') }}</Badge>
</div>
<!-- Epics Table -->
<Card v-if="workItems.epics.length > 0" id="dashboard-epics-table">
<CardHeader class="pb-2">
<CardTitle class="text-sm font-medium">{{ t('dashboard.epicsCount', { count: workItems.totalEpics }) }}</CardTitle>
</CardHeader>
<CardContent v-if="epic.description" class="p-4 pt-0">
<p class="text-xs text-muted-foreground line-clamp-2">{{ epic.description }}</p>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead class="w-[80px]">{{ t('dashboard.code') }}</TableHead>
<TableHead class="w-[60px]">{{ t('users.role') }}</TableHead>
<TableHead>{{ t('dashboard.title') }}</TableHead>
<TableHead class="w-[60px] text-center">Desc</TableHead>
<TableHead class="w-[90px] text-center">{{ t('dashboard.assignedTo') }}</TableHead>
<TableHead class="w-[100px]">{{ t('dashboard.status') }}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="epic in workItems.epics" :key="epic.id">
<TableCell class="font-mono text-xs text-muted-foreground">{{ epic.code || `EP-${epic.id}` }}</TableCell>
<TableCell>
<span class="inline-flex items-center rounded px-1 py-0.5 text-[10px] font-bold" :class="getTypeColor(epic._itemType)">{{ getTypeLabel(epic._itemType) }}</span>
</TableCell>
<TableCell class="text-sm">{{ epic._cleanName || epic.name || epic.title || t('dashboard.epicFallback', { id: epic.id }) }}</TableCell>
<TableCell class="text-center">
<Dialog v-if="epic.description">
<DialogTrigger as-child>
<Button variant="ghost" size="icon" class="size-6" @click="openDescription(epic._cleanName || epic.name || '', epic.description || '')">
<Eye class="size-3.5 text-muted-foreground" />
</Button>
</DialogTrigger>
</Dialog>
<span v-else class="text-xs text-muted-foreground/40"></span>
</TableCell>
<TableCell class="text-center text-xs text-muted-foreground"></TableCell>
<TableCell>
<Badge :variant="statusVariant(epic.status || '')" class="text-xs">{{ statusLabel(epic.status || '') }}</Badge>
</TableCell>
</TableRow>
</TableBody>
</Table>
</CardContent>
</Card>
</div>
</div>
</template>
<!-- QA Plans -->
<Card v-if="qaPlans.length > 0" id="dashboard-qa-plans">
@@ -727,6 +755,7 @@ const statusLabel = (status: unknown) => {
<TableHead class="w-[80px]">{{ t('dashboard.code') }}</TableHead>
<TableHead class="w-[60px]">{{ t('users.role') }}</TableHead>
<TableHead>{{ t('dashboard.title') }}</TableHead>
<TableHead class="w-[50px] text-center">Desc</TableHead>
<TableHead class="w-[50px] text-center">SP</TableHead>
<TableHead class="w-[110px]">{{ t('dashboard.status') }}</TableHead>
<TableHead class="w-[90px]">{{ t('dashboard.priority') }}</TableHead>
@@ -750,6 +779,12 @@ const statusLabel = (status: unknown) => {
</div>
</span>
</TableCell>
<TableCell class="text-center">
<Button v-if="hu.description" variant="ghost" size="icon" class="size-6" @click="openDescription(hu._cleanTitle || hu.title || '', hu.description)">
<Eye class="size-3.5 text-muted-foreground" />
</Button>
<span v-else class="text-xs text-muted-foreground/40"></span>
</TableCell>
<TableCell class="text-center text-xs font-mono text-muted-foreground">{{ hu.story_points ?? '—' }}</TableCell>
<TableCell><Badge :variant="statusVariant(hu.status || '')" class="text-xs capitalize">{{ hu._statusName || statusLabel(hu.status || '') }}</Badge></TableCell>
<TableCell><Badge :variant="priorityVariant(hu.priority)" class="text-xs">{{ priorityLabel(hu.priority) }}</Badge></TableCell>
@@ -763,6 +798,31 @@ const statusLabel = (status: unknown) => {
<p v-else class="text-xs text-muted-foreground/50 italic text-center py-4">{{ t('dashboard.noUserStories') }}</p>
</CardContent>
</Card>
<!-- Description Modal -->
<Dialog v-model:open="descModalOpen">
<DialogContent class="sm:max-w-[700px] max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle class="text-sm">{{ descModal?.title }}</DialogTitle>
</DialogHeader>
<div class="text-xs leading-relaxed whitespace-pre-wrap text-muted-foreground" v-html="descModal?.content"></div>
</DialogContent>
</Dialog>
<!-- Inline notification (funciona en Tauri) -->
<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'"
>
<CheckCircle2 v-if="notification.type === 'success'" class="size-4 text-green-500 shrink-0 mt-0.5" />
<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>
<p class="text-muted-foreground mt-0.5">{{ notification.message }}</p>
</div>
<button class="text-muted-foreground hover:text-foreground shrink-0" @click="notification = null">&times;</button>
</div>
</div>
<div v-else class="flex flex-1 items-center justify-center">
-2
View File
@@ -10,7 +10,6 @@ import ProjectListView from "@/views/ProjectListView.vue"
import UsersView from "@/views/UsersView.vue"
import TranscriptionsView from "@/views/TranscriptionsView.vue"
import SettingsView from "@/views/SettingsView.vue"
import ToastNotification from "@/components/ToastNotification.vue"
const { t } = useI18n()
@@ -172,6 +171,5 @@ const tabContent: Record<string, { title: string; description: string; cards: {
</div>
</div>
</SidebarInset>
<ToastNotification />
</SidebarProvider>
</template>