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:
@@ -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,
|
||||
|
||||
+102
-42
@@ -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>
|
||||
</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>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- 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>
|
||||
<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>
|
||||
|
||||
<!-- 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">×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-1 items-center justify-center">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user