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:
@@ -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)">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@keyframes slideIn {
|
||||||
|
from { transform: translateX(100%); opacity: 0; }
|
||||||
|
to { transform: translateX(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -52,6 +52,7 @@ export interface HuDraftRecord {
|
|||||||
description: string
|
description: string
|
||||||
acceptanceCriteria: string
|
acceptanceCriteria: string
|
||||||
priority: string
|
priority: string
|
||||||
|
story_points?: number
|
||||||
type: string // 'U' = HU, 'E' = Epic, 'F' = Feature, 'T' = Task, 'B' = Bug
|
type: string // 'U' = HU, 'E' = Epic, 'F' = Feature, 'T' = Task, 'B' = Bug
|
||||||
metadata: string // JSON opcional: { linkedHuTitles?: string[], estimatedStart?: string, estimatedEnd?: string }
|
metadata: string // JSON opcional: { linkedHuTitles?: string[], estimatedStart?: string, estimatedEnd?: string }
|
||||||
sourceSessionId?: number
|
sourceSessionId?: number
|
||||||
|
|||||||
+36
-25
@@ -11,6 +11,7 @@ import { Button } from '@/components/ui/button'
|
|||||||
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 { analyzeProject, saveAsDrafts } from '@/services/project-analyzer'
|
import { analyzeProject, saveAsDrafts } from '@/services/project-analyzer'
|
||||||
|
import { useToast } from '@/composables/useToast'
|
||||||
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'
|
||||||
import { generateAndSavePlan, getQAPlans, type HUQAPlan } from '@/services/qa-analyzer'
|
import { generateAndSavePlan, getQAPlans, type HUQAPlan } from '@/services/qa-analyzer'
|
||||||
@@ -36,7 +37,7 @@ const { t } = useI18n()
|
|||||||
const projects = useProjectsStore()
|
const projects = useProjectsStore()
|
||||||
const workItems = useWorkItemsStore()
|
const workItems = useWorkItemsStore()
|
||||||
const usersStore = useUsersStore()
|
const usersStore = useUsersStore()
|
||||||
|
const { show: showToast } = useToast()
|
||||||
const project = computed(() => projects.selected)
|
const project = computed(() => projects.selected)
|
||||||
|
|
||||||
// ─── Team members for this project ────────────────────
|
// ─── Team members for this project ────────────────────
|
||||||
@@ -267,57 +268,64 @@ async function pushDraft(d: HuDraftRecord) {
|
|||||||
const token = localStorage.getItem('kappa_token')
|
const token = localStorage.getItem('kappa_token')
|
||||||
const headers = { 'Content-Type': 'application/json', ...(token ? { 'Authorization': `Bearer ${token}` } : {}) }
|
const headers = { 'Content-Type': 'application/json', ...(token ? { 'Authorization': `Bearer ${token}` } : {}) }
|
||||||
|
|
||||||
|
let endpoint: string, body: string
|
||||||
|
|
||||||
if (d.type === 'E') {
|
if (d.type === 'E') {
|
||||||
// Push épica: buscar HUs vinculadas ya enviadas
|
// Push épica
|
||||||
const meta = JSON.parse(d.metadata || '{}')
|
const meta = JSON.parse(d.metadata || '{}')
|
||||||
const linkedHuIds: number[] = []
|
const linkedHuIds: number[] = []
|
||||||
for (const huTitle of meta.linkedHuTitles || []) {
|
for (const huTitle of meta.linkedHuTitles || []) {
|
||||||
const pushedHu = drafts.value.find(x => x.type !== 'E' && (x.title.toLowerCase().trim() === huTitle.toLowerCase().trim()) && x.kappaId)
|
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)
|
if (pushedHu?.kappaId) linkedHuIds.push(pushedHu.kappaId)
|
||||||
}
|
}
|
||||||
|
endpoint = '/api/epicdevelopment/create/'
|
||||||
const res = await fetch('/api/epicdevelopment/create/', {
|
body = JSON.stringify({
|
||||||
method: 'POST', headers,
|
initiative: String(d.projectId),
|
||||||
body: JSON.stringify({
|
name: d.title,
|
||||||
initiative: String(d.projectId), name: d.title,
|
|
||||||
description: d.description ? `<p>${d.description.replace(/\n/g, '</p><p>')}</p>` : '',
|
description: d.description ? `<p>${d.description.replace(/\n/g, '</p><p>')}</p>` : '',
|
||||||
client_taker: null, hu: linkedHuIds,
|
|
||||||
stimated_start_date: meta.estimatedStart || null,
|
stimated_start_date: meta.estimatedStart || null,
|
||||||
stimated_end_date: meta.estimatedEnd || 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 {
|
} else {
|
||||||
d.syncStatus = 'draft'; await dbSaveDraft(d)
|
// Push HU
|
||||||
}
|
endpoint = '/api/userstorys/create/'
|
||||||
} else {
|
body = JSON.stringify({
|
||||||
// Push HU individual
|
|
||||||
const res = await fetch('/api/userstorys/create/', {
|
|
||||||
method: 'POST', headers,
|
|
||||||
body: JSON.stringify({
|
|
||||||
initiative: String(d.projectId), title: d.title,
|
initiative: String(d.projectId), title: d.title,
|
||||||
description: d.description ? `<p>${d.description.replace(/\n/g, '</p><p>')}</p>` : '',
|
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>` : '',
|
criterios_aceptacion: d.acceptanceCriteria ? `<p>${d.acceptanceCriteria.replace(/\n/g, '</p><p>')}</p>` : '',
|
||||||
story_points: '',
|
story_points: String(d.story_points ?? ''),
|
||||||
priority: d.priority === 'Alta' ? '1' : d.priority === 'Baja' ? '3' : '2',
|
priority: d.priority === 'Alta' ? '1' : d.priority === 'Baja' ? '3' : '2',
|
||||||
sprint: '', asignado_a: [], client_taker: null,
|
sprint: '', asignado_a: [], client_taker: null,
|
||||||
characterization_hu: '', has_impairment: false,
|
characterization_hu: '', has_impairment: false,
|
||||||
epic_development: null, feature: '',
|
epic_development: null, feature: '',
|
||||||
initial_date: null, end_date: null,
|
initial_date: null, end_date: null,
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(endpoint, { method: 'POST', headers, body })
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
if (d.type !== 'E') {
|
||||||
const created = await res.json()
|
const created = await res.json()
|
||||||
d.kappaId = created.id || undefined
|
d.kappaId = created.id || undefined
|
||||||
|
}
|
||||||
d.syncStatus = 'pushed'
|
d.syncStatus = 'pushed'
|
||||||
await dbSaveDraft(d)
|
await dbSaveDraft(d)
|
||||||
|
showToast('success', d.type === 'E' ? 'Épica creada en KAPPA' : 'HU creada en KAPPA', d.title.slice(0, 100))
|
||||||
} else {
|
} else {
|
||||||
d.syncStatus = 'draft'; await dbSaveDraft(d)
|
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 (e: any) {
|
||||||
} catch {
|
console.error('[Alpha] Error en pushDraft:', e)
|
||||||
d.syncStatus = 'draft'; await dbSaveDraft(d)
|
showToast('error', 'Error de red', e.message)
|
||||||
|
d.syncStatus = 'draft'
|
||||||
|
await dbSaveDraft(d)
|
||||||
} finally {
|
} finally {
|
||||||
pushingDraftId.value = null
|
pushingDraftId.value = null
|
||||||
await loadDrafts()
|
await loadDrafts()
|
||||||
@@ -420,7 +428,10 @@ const statusLabel = (status: unknown) => {
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="project" class="@container/main flex flex-1 flex-col gap-4 px-4 lg:px-6">
|
<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">
|
<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">
|
<div class="flex items-center gap-2">
|
||||||
<Badge v-if="project.key" variant="outline" class="text-xs">{{ project.key }}</Badge>
|
<Badge v-if="project.key" variant="outline" class="text-xs">{{ project.key }}</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import ProjectListView from "@/views/ProjectListView.vue"
|
|||||||
import UsersView from "@/views/UsersView.vue"
|
import UsersView from "@/views/UsersView.vue"
|
||||||
import TranscriptionsView from "@/views/TranscriptionsView.vue"
|
import TranscriptionsView from "@/views/TranscriptionsView.vue"
|
||||||
import SettingsView from "@/views/SettingsView.vue"
|
import SettingsView from "@/views/SettingsView.vue"
|
||||||
|
import ToastNotification from "@/components/ToastNotification.vue"
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
@@ -171,5 +172,6 @@ const tabContent: Record<string, { title: string; description: string; cards: {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
|
<ToastNotification />
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -231,6 +231,7 @@ onMounted(async () => {
|
|||||||
<!-- Title + Status -->
|
<!-- Title + Status -->
|
||||||
<div class="flex items-start justify-between gap-2">
|
<div class="flex items-start justify-between gap-2">
|
||||||
<CardTitle class="text-base">
|
<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 }) }}
|
{{ p.initiative_name || p.name || t('projects.unnamedFallback', { id: p.id }) }}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Badge id="projects-status-badge" :variant="getStatusVariant(p.status)">
|
<Badge id="projects-status-badge" :variant="getStatusVariant(p.status)">
|
||||||
|
|||||||
Reference in New Issue
Block a user