QA plans al sugerir HU + cancel analisis + datepicker shadcn

- project-analyzer: saveAsDrafts genera QA plan por cada HU sugerida
- DashboardView: cancelAnalysis con AbortController + mensaje limpio
- HuDrafts: DatePicker con Calendar + Popover (shadcn-vue)
- HuDrafts: formulario dinámico segun tipo (Epic vs HU/Feature/etc)
- components/ui: Popover + Calendar creados
- qa_plans: tabla separada (cubre drafts + user_stories existentes)
This commit is contained in:
2026-05-28 23:25:00 -05:00
parent dd9f76be6f
commit 8667cddc46
10 changed files with 204 additions and 42 deletions
+4 -1
View File
@@ -5,6 +5,7 @@
"": {
"name": "kappa-hub",
"dependencies": {
"@internationalized/date": "^3.12.2",
"@lucide/vue": "^1.16.0",
"@tabler/icons-vue": "^3.44.0",
"@tailwindcss/vite": "^4.3.0",
@@ -243,7 +244,7 @@
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
"@internationalized/date": ["@internationalized/date@3.12.1", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-6IedsVWXyq4P9Tj+TxuU8WGWM70hYLl12nbYU8jkikVpa6WXapFazPUcHUMDMoWftIDE2ILDkFFte6W2nFCkRQ=="],
"@internationalized/date": ["@internationalized/date@3.12.2", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw=="],
"@internationalized/number": ["@internationalized/number@3.6.6", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-iFgmQaXHE0vytNfpLZWOC2mEJCBRzcUxt53Xf/yCXG93lRvqas237i3r7X4RKMwO3txiyZD4mQjKAByFv6UGSQ=="],
@@ -1309,6 +1310,8 @@
"recast-x/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"reka-ui/@internationalized/date": ["@internationalized/date@3.12.1", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-6IedsVWXyq4P9Tj+TxuU8WGWM70hYLl12nbYU8jkikVpa6WXapFazPUcHUMDMoWftIDE2ILDkFFte6W2nFCkRQ=="],
"restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
"restore-cursor/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
+1
View File
@@ -11,6 +11,7 @@
"tauri": "npx tauri"
},
"dependencies": {
"@internationalized/date": "^3.12.2",
"@lucide/vue": "^1.16.0",
"@tabler/icons-vue": "^3.44.0",
"@tailwindcss/vite": "^4.3.0",
+47 -5
View File
@@ -10,7 +10,9 @@ import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Plus, Send, Pencil, CheckCircle2, Clock } from 'lucide-vue-next'
import { Popover } from '@/components/ui/popover'
import { Calendar } from '@/components/ui/calendar'
import { Plus, Send, Pencil, CheckCircle2, Clock, Calendar as CalendarIcon } from 'lucide-vue-next'
const { t } = useI18n()
@@ -136,6 +138,23 @@ async function pushAllToKappa() {
}
}
function parseDate(s: string): Date | undefined {
if (!s) return undefined
const d = new Date(s + 'T00:00:00')
return isNaN(d.getTime()) ? undefined : d
}
function formatDate(d: Date | undefined): string {
if (!d) return ''
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
function updateEpicDate(value: string | number | null, idx: 0 | 1) {
const parts = (form.value.code || '').split('|')
parts[idx] = String(value ?? '')
form.value.code = parts.join('|') || null
}
onMounted(loadDrafts)
</script>
@@ -154,7 +173,6 @@ onMounted(loadDrafts)
<!-- Form -->
<Card v-if="showForm" class="mb-4">
<CardContent class="p-4 space-y-3">
<div class="grid grid-cols-2 gap-3">
<Select v-model="form.item_type">
<SelectTrigger class="w-full">
<SelectValue :placeholder="t('users.role')" />
@@ -165,13 +183,37 @@ onMounted(loadDrafts)
</SelectItem>
</SelectContent>
</Select>
<Input :model-value="form.story_points ?? ''" @update:model-value="form.story_points = $event ? Number($event) : null" type="number" placeholder="SP" step="0.5" min="0" />
</div>
<Input v-model="form.title" :placeholder="t('dashboard.title')" />
<Input :model-value="form.title" @update:model-value="form.title = String($event ?? '')" :placeholder="t('dashboard.title')" />
<Input :model-value="form.description ?? ''" @update:model-value="form.description = String($event ?? '') || null" :placeholder="t('dashboard.description')" />
<template v-if="form.item_type === 'E'">
<div class="grid grid-cols-2 gap-3">
<Popover>
<template #trigger>
<Button variant="outline" class="w-full justify-start text-sm font-normal">
<CalendarIcon class="size-4 mr-2" />
<span>{{ (form.code || '').split('|')[0] || 'Inicio estimado' }}</span>
</Button>
</template>
<Calendar :model-value="parseDate((form.code || '').split('|')[0])" @update:model-value="updateEpicDate(formatDate($event), 0)" />
</Popover>
<Popover>
<template #trigger>
<Button variant="outline" class="w-full justify-start text-sm font-normal">
<CalendarIcon class="size-4 mr-2" />
<span>{{ (form.code || '').split('|')[1] || 'Fin estimado' }}</span>
</Button>
</template>
<Calendar :model-value="parseDate((form.code || '').split('|')[1])" @update:model-value="updateEpicDate(formatDate($event), 1)" />
</Popover>
</div>
</template>
<template v-else>
<div class="grid grid-cols-3 gap-3">
<Input :model-value="form.story_points ?? ''" @update:model-value="form.story_points = $event ? Number($event) : null" type="number" placeholder="SP" step="0.5" min="0" />
<Input :model-value="form.sprint ?? ''" @update:model-value="form.sprint = String($event ?? '') || null" placeholder="Sprint" />
<Input :model-value="form.assigned_to ?? ''" @update:model-value="form.assigned_to = String($event ?? '') || null" placeholder="Asignado a" />
</div>
</template>
<div class="flex gap-2">
<Button size="sm" @click="saveDraft">Guardar</Button>
<Button size="sm" variant="outline" @click="showForm = false">Cancelar</Button>
+71
View File
@@ -0,0 +1,71 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { type HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
const props = defineProps<{
modelValue?: Date
class?: HTMLAttributes['class']
}>()
const emit = defineEmits<{ 'update:modelValue': [value: Date | undefined] }>()
const viewDate = ref(new Date())
const monthNames = ['Enero','Febrero','Marzo','Abril','Mayo','Junio','Julio','Agosto','Septiembre','Octubre','Noviembre','Diciembre']
const dayNames = ['Do','Lu','Ma','Mi','Ju','Vi','Sa']
const daysInMonth = computed(() => new Date(viewDate.value.getFullYear(), viewDate.value.getMonth() + 1, 0).getDate())
const firstDay = computed(() => new Date(viewDate.value.getFullYear(), viewDate.value.getMonth(), 1).getDay())
const today = new Date()
const selectedDate = ref(props.modelValue)
function prevMonth() { viewDate.value = new Date(viewDate.value.getFullYear(), viewDate.value.getMonth() - 1, 1) }
function nextMonth() { viewDate.value = new Date(viewDate.value.getFullYear(), viewDate.value.getMonth() + 1, 1) }
function selectDay(d: number) {
const date = new Date(viewDate.value.getFullYear(), viewDate.value.getMonth(), d)
date.setHours(0, 0, 0, 0)
selectedDate.value = date
emit('update:modelValue', date)
}
function isSelected(d: number) {
if (!selectedDate.value) return false
return selectedDate.value.getFullYear() === viewDate.value.getFullYear() &&
selectedDate.value.getMonth() === viewDate.value.getMonth() &&
selectedDate.value.getDate() === d
}
function isToday(d: number) {
return today.getFullYear() === viewDate.value.getFullYear() &&
today.getMonth() === viewDate.value.getMonth() &&
today.getDate() === d
}
</script>
<template>
<div :class="cn('p-3', props.class)">
<div class="flex items-center justify-between mb-2">
<Button variant="ghost" size="icon" class="size-7" @click="prevMonth"><ChevronLeft class="size-4" /></Button>
<span class="text-sm font-medium">{{ monthNames[viewDate.getMonth()] }} {{ viewDate.getFullYear() }}</span>
<Button variant="ghost" size="icon" class="size-7" @click="nextMonth"><ChevronRight class="size-4" /></Button>
</div>
<div class="grid grid-cols-7 gap-0 text-center text-xs mb-1">
<span v-for="d in dayNames" :key="d" class="text-muted-foreground py-1">{{ d }}</span>
</div>
<div class="grid grid-cols-7 gap-0 text-center text-sm">
<span v-for="_ in firstDay" :key="'e'+_" />
<button
v-for="d in daysInMonth"
:key="d"
class="py-1.5 rounded hover:bg-muted transition-colors relative"
:class="[isSelected(d) ? 'bg-primary text-primary-foreground hover:bg-primary' : '', isToday(d) && !isSelected(d) ? 'bg-accent text-accent-foreground' : '']"
@click="selectDay(d)"
>{{ d }}</button>
</div>
</div>
</template>
+1
View File
@@ -0,0 +1 @@
export { default as Calendar } from './Calendar.vue'
+23
View File
@@ -0,0 +1,23 @@
<script setup lang="ts">
import { type HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import {
PopoverContent,
PopoverRoot,
PopoverTrigger,
type PopoverContentProps,
} from 'reka-ui'
const props = defineProps<PopoverContentProps & { class?: HTMLAttributes['class'] }>()
</script>
<template>
<PopoverRoot>
<PopoverTrigger as-child>
<slot name="trigger" />
</PopoverTrigger>
<PopoverContent v-bind="props" :class="cn('z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none', props.class)">
<slot />
</PopoverContent>
</PopoverRoot>
</template>
+1
View File
@@ -0,0 +1 @@
export { default as Popover } from './Popover.vue'
+1 -1
View File
@@ -320,9 +320,9 @@
},
"hierarchy": {
"epic": "Epic",
"hu": "HU",
"feature": "Feature",
"task": "Task",
"hu": "HU",
"bug": "Bug"
},
"workitems": {
+6 -1
View File
@@ -1,6 +1,7 @@
import { callAI } from '@/services/ai'
import { getSessionsByProject, getSessionSummary, getProjectState } from '@/services/transcriptions-db'
import { saveDraft, createDraftId } from '@/services/hu-drafts-db'
import { generateAndSavePlan } from '@/services/qa-analyzer'
import type { EnrichedUserStory } from '@/stores/workitems'
export interface AnalysisHU {
@@ -129,12 +130,16 @@ export async function saveAsDrafts(
})
if (isDuplicate) { skipped++; continue }
const draftId = createDraftId()
await saveDraft({
id: createDraftId(), projectId, title: hu.title,
id: draftId, projectId, title: hu.title,
description: hu.description, acceptanceCriteria: hu.acceptance_criteria.join('\n'),
priority: hu.priority, type: 'U', metadata: '{}',
sourceSessionId, syncStatus: 'draft', createdAt: new Date().toISOString(),
})
// QA plan: fire-and-forget para no bloquear el guardado
generateAndSavePlan(projectId, draftId, hu.title, hu.description, hu.acceptance_criteria.join('\n'))
.catch(e => console.error(`[Alpha] QA auto-gen failed for ${hu.title}:`, e))
saved++
}
+24 -9
View File
@@ -108,10 +108,6 @@ async function pushDraft(d: HuDraftRecord) {
})
if (res.ok) {
d.syncStatus = 'pushed'; await dbSaveDraft(d)
// Auto-generar QA plan tras push exitoso de épica
generateAndSavePlan(d.projectId, d.id, d.title, d.description || '', '').catch(e =>
console.error(`[Alpha] QA plan auto-gen failed for epic ${d.title}:`, e)
)
} else {
d.syncStatus = 'draft'; await dbSaveDraft(d)
}
@@ -136,10 +132,6 @@ async function pushDraft(d: HuDraftRecord) {
d.kappaId = created.id || undefined
d.syncStatus = 'pushed'
await dbSaveDraft(d)
// Auto-generar QA plan tras push exitoso
generateAndSavePlan(d.projectId, d.id, d.title, d.description || '', d.acceptanceCriteria).catch(e =>
console.error(`[Alpha] QA plan auto-gen failed for ${d.title}:`, e)
)
} else {
d.syncStatus = 'draft'; await dbSaveDraft(d)
}
@@ -168,17 +160,26 @@ async function discardDraft(id: string) {
// ─── Project analysis ────────────────────────────────────
const analyzing = ref(false)
const analysisAbort = ref<AbortController | null>(null)
const analysisResult = ref<{ saved: number; skipped: number } | null>(null)
const analysisSummary = ref('')
function cancelAnalysis() {
analysisAbort.value?.abort()
analyzing.value = false
analysisAbort.value = null
analysisSummary.value = 'Análisis cancelado'
}
async function runAnalysis() {
if (!project.value) return
analyzing.value = true
analysisAbort.value = new AbortController()
analysisResult.value = null
analysisSummary.value = ''
try {
const result = await analyzeProject(project.value.id, project.value.name || '', workItems.userStories)
const result = await analyzeProject(project.value.id, project.value.name || '', workItems.userStories, analysisAbort.value?.signal)
analysisSummary.value = result.summary
if (result.hus.length > 0) {
@@ -188,8 +189,12 @@ async function runAnalysis() {
analysisResult.value = { saved: 0, skipped: 0 }
}
} catch (e: any) {
if (e.name === 'AbortError' || e.message?.includes('aborted')) {
analysisSummary.value = 'Análisis cancelado'
} else {
console.error('[Alpha] Analysis error:', e)
analysisSummary.value = `Error: ${e.message}`
}
analysisResult.value = { saved: 0, skipped: 0 }
} finally {
analyzing.value = false
@@ -313,6 +318,15 @@ const statusLabel = (status: unknown) => {
<Sparkles class="size-4" />
Análisis completo del proyecto
</CardTitle>
<div class="flex gap-2">
<Button
v-if="analyzing"
size="sm"
variant="outline"
@click="cancelAnalysis()"
>
Cancelar
</Button>
<Button
size="sm"
:disabled="analyzing"
@@ -322,6 +336,7 @@ const statusLabel = (status: unknown) => {
<Sparkles v-else class="size-4 mr-1" />
{{ analyzing ? 'Analizando...' : 'Generar HUs faltantes' }}
</Button>
</div>
</CardHeader>
<CardContent v-if="analysisResult" class="space-y-2 text-sm">
<p class="text-muted-foreground">{{ analysisSummary }}</p>