ProjectListView + DashboardView: mostrar ID del proyecto (#305) junto al nombre

- pushDraft: épica usa JSON con client_taker + status=false
- ToastNotification: sistema de notificaciones toast global
- useToast: composable singleton para mostrar/descartar toasts
This commit is contained in:
2026-05-29 12:01:11 -05:00
parent 19c6fb3153
commit b45caee583
6 changed files with 125 additions and 39 deletions
+42
View File
@@ -0,0 +1,42 @@
<script setup lang="ts">
import { useToast } from '@/composables/useToast'
import { CheckCircle2, XCircle, Info, X } from 'lucide-vue-next'
const { toasts, dismiss } = useToast()
const icons: Record<string, any> = {
success: CheckCircle2,
error: XCircle,
info: Info,
}
const colors: Record<string, string> = {
success: 'border-l-green-500',
error: 'border-l-red-500',
info: 'border-l-blue-500',
}
</script>
<template>
<div class="fixed bottom-4 right-4 z-50 flex flex-col gap-2 max-w-sm">
<div
v-for="t in toasts"
:key="t.id"
:class="['flex items-start gap-3 p-3 rounded-lg border bg-card border-l-[3px] text-xs shadow-lg animate-[slideIn_0.3s_ease]', colors[t.type]]"
>
<component :is="icons[t.type]" class="size-4 shrink-0 mt-0.5" :class="t.type === 'success' ? 'text-green-500' : t.type === 'error' ? 'text-red-500' : 'text-blue-500'" />
<div class="flex-1 min-w-0">
<p class="font-medium text-foreground">{{ t.title }}</p>
<p v-if="t.message" class="text-muted-foreground mt-0.5 break-words">{{ t.message }}</p>
</div>
<button class="text-muted-foreground hover:text-foreground shrink-0" @click="dismiss(t.id)">&times;</button>
</div>
</div>
</template>
<style scoped>
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
</style>
+29
View File
@@ -0,0 +1,29 @@
import { ref } from 'vue'
export interface Toast {
id: string
type: 'success' | 'error' | 'info'
title: string
message?: string
}
const toasts = ref<Toast[]>([])
let counter = 0
export function useToast() {
function show(type: Toast['type'], title: string, message?: string, duration = 5000) {
const id = `toast-${++counter}`
toasts.value.push({ id, type, title, message })
if (duration > 0) {
setTimeout(() => dismiss(id), duration)
}
}
function dismiss(id: string) {
const idx = toasts.value.findIndex(t => t.id === id)
if (idx !== -1) toasts.value.splice(idx, 1)
}
return { toasts, show, dismiss }
}
+1
View File
@@ -52,6 +52,7 @@ export interface HuDraftRecord {
description: string
acceptanceCriteria: string
priority: string
story_points?: number
type: string // 'U' = HU, 'E' = Epic, 'F' = Feature, 'T' = Task, 'B' = Bug
metadata: string // JSON opcional: { linkedHuTitles?: string[], estimatedStart?: string, estimatedEnd?: string }
sourceSessionId?: number
+50 -39
View File
@@ -11,6 +11,7 @@ import { Button } from '@/components/ui/button'
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'
@@ -36,7 +37,7 @@ const { t } = useI18n()
const projects = useProjectsStore()
const workItems = useWorkItemsStore()
const usersStore = useUsersStore()
const { show: showToast } = useToast()
const project = computed(() => projects.selected)
// ─── Team members for this project ────────────────────
@@ -267,57 +268,64 @@ async function pushDraft(d: HuDraftRecord) {
const token = localStorage.getItem('kappa_token')
const headers = { 'Content-Type': 'application/json', ...(token ? { 'Authorization': `Bearer ${token}` } : {}) }
let endpoint: string, body: string
if (d.type === 'E') {
// Push épica: buscar HUs vinculadas ya enviadas
// Push épica
const meta = JSON.parse(d.metadata || '{}')
const linkedHuIds: number[] = []
for (const huTitle of meta.linkedHuTitles || []) {
const pushedHu = drafts.value.find(x => x.type !== 'E' && (x.title.toLowerCase().trim() === huTitle.toLowerCase().trim()) && x.kappaId)
if (pushedHu?.kappaId) linkedHuIds.push(pushedHu.kappaId)
}
const res = await fetch('/api/epicdevelopment/create/', {
method: 'POST', headers,
body: JSON.stringify({
initiative: String(d.projectId), name: d.title,
description: d.description ? `<p>${d.description.replace(/\n/g, '</p><p>')}</p>` : '',
client_taker: null, hu: linkedHuIds,
stimated_start_date: meta.estimatedStart || null,
stimated_end_date: meta.estimatedEnd || null,
}),
endpoint = '/api/epicdevelopment/create/'
body = JSON.stringify({
initiative: String(d.projectId),
name: d.title,
description: d.description ? `<p>${d.description.replace(/\n/g, '</p><p>')}</p>` : '',
stimated_start_date: meta.estimatedStart || null,
stimated_end_date: meta.estimatedEnd || null,
client_taker: Number(meta.clientTaker) || null,
hu: linkedHuIds,
status: false,
})
if (res.ok) {
d.syncStatus = 'pushed'; await dbSaveDraft(d)
} else {
d.syncStatus = 'draft'; await dbSaveDraft(d)
}
} else {
// Push HU individual
const res = await fetch('/api/userstorys/create/', {
method: 'POST', headers,
body: JSON.stringify({
initiative: String(d.projectId), title: d.title,
description: d.description ? `<p>${d.description.replace(/\n/g, '</p><p>')}</p>` : '',
criterios_aceptacion: d.acceptanceCriteria ? `<p>${d.acceptanceCriteria.replace(/\n/g, '</p><p>')}</p>` : '',
story_points: '',
priority: d.priority === 'Alta' ? '1' : d.priority === 'Baja' ? '3' : '2',
sprint: '', asignado_a: [], client_taker: null,
characterization_hu: '', has_impairment: false,
epic_development: null, feature: '',
initial_date: null, end_date: null,
}),
// Push HU
endpoint = '/api/userstorys/create/'
body = JSON.stringify({
initiative: String(d.projectId), title: d.title,
description: d.description ? `<p>${d.description.replace(/\n/g, '</p><p>')}</p>` : '',
criterios_aceptacion: d.acceptanceCriteria ? `<p>${d.acceptanceCriteria.replace(/\n/g, '</p><p>')}</p>` : '',
story_points: String(d.story_points ?? ''),
priority: d.priority === 'Alta' ? '1' : d.priority === 'Baja' ? '3' : '2',
sprint: '', asignado_a: [], client_taker: null,
characterization_hu: '', has_impairment: false,
epic_development: null, feature: '',
initial_date: null, end_date: null,
})
if (res.ok) {
}
const res = await fetch(endpoint, { method: 'POST', headers, body })
if (res.ok) {
if (d.type !== 'E') {
const created = await res.json()
d.kappaId = created.id || undefined
d.syncStatus = 'pushed'
await dbSaveDraft(d)
} else {
d.syncStatus = 'draft'; await dbSaveDraft(d)
}
d.syncStatus = 'pushed'
await dbSaveDraft(d)
showToast('success', d.type === 'E' ? 'Épica creada en KAPPA' : 'HU creada en KAPPA', d.title.slice(0, 100))
} 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))
d.syncStatus = 'draft'
await dbSaveDraft(d)
}
} catch {
d.syncStatus = 'draft'; await dbSaveDraft(d)
} catch (e: any) {
console.error('[Alpha] Error en pushDraft:', e)
showToast('error', 'Error de red', e.message)
d.syncStatus = 'draft'
await dbSaveDraft(d)
} finally {
pushingDraftId.value = null
await loadDrafts()
@@ -420,7 +428,10 @@ const statusLabel = (status: unknown) => {
<template>
<div v-if="project" class="@container/main flex flex-1 flex-col gap-4 px-4 lg:px-6">
<div class="flex flex-col gap-1">
<h1 class="text-2xl font-bold tracking-tight">{{ project.name }}</h1>
<h1 class="text-2xl font-bold tracking-tight">
<span class="font-mono text-sm text-muted-foreground mr-2">#{{ project.id }}</span>
{{ project.name }}
</h1>
<div class="flex items-center gap-2">
<Badge v-if="project.key" variant="outline" class="text-xs">{{ project.key }}</Badge>
</div>
+2
View File
@@ -10,6 +10,7 @@ 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()
@@ -171,5 +172,6 @@ const tabContent: Record<string, { title: string; description: string; cards: {
</div>
</div>
</SidebarInset>
<ToastNotification />
</SidebarProvider>
</template>
+1
View File
@@ -231,6 +231,7 @@ onMounted(async () => {
<!-- Title + Status -->
<div class="flex items-start justify-between gap-2">
<CardTitle class="text-base">
<span class="font-mono text-xs text-muted-foreground mr-1.5">#{{ p.id }}</span>
{{ p.initiative_name || p.name || t('projects.unnamedFallback', { id: p.id }) }}
</CardTitle>
<Badge id="projects-status-badge" :variant="getStatusVariant(p.status)">